Agent Runtime Claude Code 2.1.139

/goal 命令实现剖析

基于 Claude Code 2.1.139 headless 版源码,拆解 /goal 命令如何通过会话级 Stop hook 驱动 Agent 持续工作直到条件满足。

源文件:2.1.139-unbundled/extracted/src/entrypoints/cli.js

复刻目标:让 Agent 持续工作直到条件满足

实现原理一句话:/goal <cond>cond 注册成一个会话级的 Stop hook(type: "prompt"。Agent 每次想结束 turn 时这个 hook 会被评估;不满足就把模型的 stop reason 作为反馈塞回去继续干活,满足后自动清除。

命名约定:每个标识符首次出现时附 /* 原始混淆名 */ 注释,方便回溯原始 bundle。

建议阅读路径
  • 先看闭环图和每节开头结论,建立整体模型。
  • 正文默认只展示关键片段;长源码放进折叠块,用作验证证据。
  • 实现时重点关注 set/clear、Stop hook 反馈、resume 恢复三条路径。

/goal 的 Stop hook 闭环

从用户设置目标,到 Stop hook 拦截结束,再到条件达成后自动清理。

Goal Stop Hook Feedback Loop A compact architecture diagram showing how /goal registers a session Stop hook, blocks stopping, feeds back the reason, and clears itself when the condition is met. /goal cond user intent Parse + Gate policy, trust Active Goal session state Stop Hook type: prompt Evaluate condition met? Clear success agent loop model/tool work not met: inject stop reason as feedback met
生成的静态 SVG 架构图;页面不需要运行任何脚本。

1. 数据模型

type ActiveGoal = {
        condition: string;        // 用户输入的目标条件
        iterations: number;       // 未达成次数(Stop hook 拦截一次 +1)
        setAt: number;            // Date.now() 建立时间
        tokensAtStart: number;    // 建立时的"累计输出 token 数"(⚠ 仅 output tokens,见 §10)
        lastReason?: string;      // 上一轮模型给出的 stop reason
      };
      
      type GoalStatusAttachment = {
        type: "goal_status";
        met: boolean;
        condition: string;
        sentinel?: boolean;       // true = 用户态 set/clear 锚点;UI 不渲染,但会被 resume 流程查找
        reason?: string;          // 未达成时的 stop reason
        iterations?: number;      // 达成时统计
        durationMs?: number;
        tokens?: number;          // 期间消耗的 output tokens
      };

appState.activeGoal 初值为 undefined(cli.js L456934)。


2. 常量与文案

const MAX_CONDITION_LENGTH /* $oH */ = 4000;
      
      const CLEAR_KEYWORDS /* l23 */ = new Set([
        "clear", "stop", "off", "reset", "none", "cancel",
      ]);
      
      const POLICY_GATE_MESSAGE /* i23 */ =
        "/goal is disabled by your organization's policy (disableAllHooks).";
      
      const TRUST_GATE_MESSAGE /* n23 */ =
        "/goal is only available in trusted workspaces. " +
        "Restart, accept the trust dialog, and try again.";
      
      // ⭐ 设置 goal 后立刻发给模型的运行时指令(照抄即可)
      const buildGoalDirective /* uD6 */ = (condition: string): string =>
        `A session-scoped Stop hook is now active with condition: "${condition}". ` +
        `Briefly acknowledge the goal, then immediately start (or continue) working toward it ` +
        `— treat the condition itself as your directive and do not pause to ask the user what to do. ` +
        `The hook will block stopping until the condition holds. ` +
        `It auto-clears once the condition is met — do not tell the user to run \`/goal clear\` after success; ` +
        `that's only for clearing a goal early.`;

3. Host-Framework 契约

目标项目必须暴露以下能力。/goal 本身 ~150 行;这层契约是真正的工程量所在。

3.1 Session & 资源访问

function getSessionId /* k_ */ (): string;            // cli.js L2288
      
      // ⚠ 注意:这是"累计 output tokens",不是"总 tokens 或 context 占用"
      function getOutputTokenCount /* Wf */ (): number;     // cli.js L2409

3.2 准入闸门

function isNonInteractive /* Z8 */ (): boolean;       // cli.js L2656 —— !isInteractive
      function isRemoteWorkspace /* V8 */ (): boolean;      // cli.js L3076
      function isWorkspaceTrusted /* q3 */ (): boolean;     // cli.js L149640
      function isHooksDisabledByPolicy /* eu */ (): boolean;// cli.js L255357

3.3 Session-scoped Hook 注册表

这是整个机制的核心。Hook 必须支持 type: "prompt",且 Stop hook 触发时框架要让 LLM 判定该 prompt 是否成立,再把结果转成 success/blocking。

展开查看实现片段(30 行)
type SessionHook =
        | { type: "prompt"; prompt: string }
        | { type: "function"; id: string; timeout: number; callback: Function; errorMessage?: string }
        | { type: "command"; command: string; /* … */ };
      
      type HookEntry = {
        matcher: string;                    // 空字符串表示"无 matcher" → /goal 用空串
        skillRoot?: string;                 // /goal 用 undefined(区别于 skill 注入的 hook)
        hooks: SessionHook[];
      };
      
      interface SessionHooksRegistry {
        add(sessionId: string, event: "Stop" | "SubagentStop" | /* … */,
            matcher: string, hook: SessionHook, skillRoot?: string): void;
        remove(sessionId: string, event: string, hook: SessionHook): void;
      }
      
      // 见 cli.js L376600 iD_ → OI7:upsert by (matcher, skillRoot)
      function installHook /* iD_ */ (
        setAppState: AppStateSetter,
        sessionId: string,
        event: "Stop",
        matcher: string,
        hook: SessionHook,
      ): void;
      
      // 取出该 session 该事件下的所有 HookEntry
      function getHooksForEvent /* twH */ (
        state: AppState, sessionId: string, event: string,
      ): Map<string, HookEntry[]>;

关键语义(cli.js L418198–418265 依赖的契约):

  • Stop hook 执行后,框架在 message stream 里 yield 一个 attachment:
    • 条件成立 → { type: "hook_success", stdout?, stderr? }
    • 条件不成立 → 同时附 blockingError: { blockingError: string },且 b.stopReason 给出原因
  • 同一帧的 b.hook 字段必须能定位回那条 { type: "prompt", prompt: cond } hook,以便 /goal 比较 prompt === activeGoal.condition

3.4 App State

interface AppStateCtx {
        getAppState(): AppState;
        setAppState(updater: (s: AppState) => AppState): void;
        applyMessageOp(op: { type: "append"; messages: Message[] }): void;
        sessionHooksRegistry: SessionHooksRegistry;
        messages: Message[];
      }
      
      interface AppState {
        activeGoal: ActiveGoal | undefined;
        // … 其它字段
      }

3.5 消息辅助

function wrapAttachmentAsMessage /* z9 */ (att: GoalStatusAttachment): Message; // cli.js L423101
      
      function makeUserMessage /* $6 */ (opts: {
        content: string | ContentBlock[];
        isMeta?: boolean;
        // …
      }): Message;                                                                    // cli.js L450191
      
      function formatBlockingError /* oV8 */ (err: { blockingError: string }): string;
      // 实现:return `Stop hook feedback:\n${err.blockingError}`                      // cli.js L554731

3.6 工具函数

function pluralize /* x6 */ (n: number, singular: string, plural?: string): string; // cli.js L9966
      function firstLine /* s5 */ (text: string): string;        // 切到第一个 \n        // cli.js L10026
      function formatDuration /* r7 */ (ms: number, opts: { mostSignificantOnly?: boolean }): string;
      function formatTokenCount /* F4 */ (n: number): string;

3.7 遥测 & UI 反馈

function track /* Q */ (event: string, props: Record<string, unknown>): void;       // cli.js L3256
      function trackWithCode /* j6 */ (event: string, code: string): void;                // cli.js L43254
      function showToast /* yH */ (id: "goal_set" | "goal_met"): void;                    // cli.js L43248

/goal 触发的事件:tengu_stop_hook_addedtengu_stop_hook_removedtengu_goal_achievedtengu_goal_restored_on_resume,失败前缀 goal_set:{policy_gate|trust_gate|too_long}


4. 命令注册(headless 关心 local 形态)

// cli.js L540226–540237
      const goalCommandNonInteractive /* gV3 */ = {
        type: "local" as const,
        name: "goal",
        supportsNonInteractive: true,
        thinClientDispatch: "post-text" as const,
        description: "Set a goal — keep working until the condition is met",
        argumentHint: "[<condition> | clear]",
        get isHidden() { return !isNonInteractive(); },
        isEnabled:  () => isNonInteractive() || isRemoteWorkspace(),
        load: () => Promise.resolve({ call: handleGoalText }),
      };

⚠ 校正:isNonInteractive() ≠ “远程客户端”。它就是 !g_.isInteractive。该命令在非交互模式默认显示非交互或远程工作区时启用。


5. 命令入口 handleGoalText(cli.js L540182–540207)

展开查看实现片段(39 行)
type GoalReply =
        | { type: "text";  value: string }
        | { type: "query"; value: string; prompt: string };
      
      async function handleGoalText /* UV3 */ (input: string, ctx: AppStateCtx): Promise<GoalReply> {
        const q = input.trim();
      
        // (1) 空输入 → 显示当前状态
        if (q === "") {
          const goal = ctx.getAppState().activeGoal;
          if (!goal) return { type: "text", value: "No goal set. Usage: `/goal <condition>`" };
      
          const turns = goal.iterations === 0
            ? "not yet evaluated"
            : `${goal.iterations} ${pluralize(goal.iterations, "turn")}`;
          const tail = goal.lastReason ? `\n${formatLastCheckLine(goal.lastReason)}` : "";
          return { type: "text", value: `Goal active: ${goal.condition} (${turns})${tail}` };
        }
      
        // (2) clear/stop/off/reset/none/cancel → 清除
        if (isClearKeyword(q)) {
          const cleared = clearGoal(ctx);
          return { type: "text", value: cleared === null ? "No goal set" : `Goal cleared: ${cleared}` };
        }
      
        // (3) 字符上限
        if (q.length > MAX_CONDITION_LENGTH) {
          trackWithCode("goal_set", "too_long");
          return {
            type: "text",
            value: `Goal condition is limited to ${MAX_CONDITION_LENGTH} characters (got ${q.length})`,
          };
        }
      
        // (4) 设置 → 失败返回错误文本;成功立即触发一次带运行时指令的 query
        const errMsg = setGoal(q, ctx);
        if (errMsg !== null) return { type: "text", value: errMsg };
        return { type: "query", value: `Goal set: ${q}`, prompt: buildGoalDirective(q) };
      }

6. 核心 set/clear 逻辑(cli.js L517416–517504)

展开查看实现片段(87 行)
function isClearKeyword /* xD6 */ (s: string): boolean {
        return CLEAR_KEYWORDS.has(s.toLowerCase());
      }
      
      function formatLastCheckLine /* hjK */ (reason: string): string {
        return `Last check: ${firstLine(reason.trim())}`;
      }
      
      // 准入闸门
      function checkGoalGate /* Wu8 */ (): { message: string; code: "policy_gate" | "trust_gate" } | null {
        if (isHooksDisabledByPolicy()) return { message: POLICY_GATE_MESSAGE, code: "policy_gate" };
        if (!isNonInteractive() && !isWorkspaceTrusted()) {
          return { message: TRUST_GATE_MESSAGE, code: "trust_gate" };
        }
        return null;
      }
      
      // 列出本会话所有"由 /goal 注入"的 Stop hooks
      function listGoalStopHooks /* mD6 */ (state: AppState, sessionId: string): SessionHook[] {
        const out: SessionHook[] = [];
        for (const entry of getHooksForEvent(state, sessionId, "Stop").get("Stop") ?? []) {
          if (entry.matcher !== "" || entry.skillRoot !== undefined) continue; // 用户态 goal 标识
          for (const hook of entry.hooks) if (hook.type === "prompt") out.push(hook);
        }
        return out;
      }
      
      // 构造 sentinel 附件消息(UI 不渲染,仅作为 resume 锚点)
      function makeGoalSentinelMessage /* EjK */ (met: boolean, condition: string): Message {
        return {
          type: "attachment",
          uuid: crypto.randomUUID(),
          timestamp: new Date().toISOString(),
          attachment: { type: "goal_status", met, sentinel: true, condition },
        };
      }
      
      // 设置 goal —— 成功返回 null,失败返回展示给用户的错误文本
      function setGoal /* YoH */ (condition: string, ctx: AppStateCtx): string | null {
        const gate = checkGoalGate();
        if (gate !== null) {
          trackWithCode("goal_set", gate.code);
          return gate.message;
        }
      
        const sessionId = getSessionId();
      
        // 同时只允许一个 goal hook:先清掉旧的
        for (const old of listGoalStopHooks(ctx.getAppState(), sessionId)) {
          ctx.sessionHooksRegistry.remove(sessionId, "Stop", old);
        }
      
        // ⭐ 把 condition 注入为 prompt 型 Stop hook
        ctx.sessionHooksRegistry.add(
          sessionId, "Stop", "",
          { type: "prompt", prompt: condition },
        );
      
        const goal: ActiveGoal = {
          condition,
          iterations: 0,
          setAt: Date.now(),
          tokensAtStart: getOutputTokenCount(),
        };
        ctx.setAppState(s => ({ ...s, activeGoal: goal }));
        ctx.applyMessageOp({ type: "append", messages: [makeGoalSentinelMessage(false, condition)] });
      
        track("tengu_stop_hook_added", { promptLength: condition.length, via: "goal" });
        showToast("goal_set");
        return null;
      }
      
      // 清除 goal —— 没有则返回 null,否则返回被清掉的 condition
      function clearGoal /* woH */ (ctx: AppStateCtx): string | null {
        const sessionId = getSessionId();
        const existing = listGoalStopHooks(ctx.getAppState(), sessionId);
        if (existing.length === 0) return null;
      
        const cleared = (existing[0] as { type: "prompt"; prompt: string }).prompt;
        for (const h of existing) ctx.sessionHooksRegistry.remove(sessionId, "Stop", h);
      
        ctx.setAppState(s => (s.activeGoal === undefined ? s : { ...s, activeGoal: undefined }));
        ctx.applyMessageOp({ type: "append", messages: [makeGoalSentinelMessage(true, cleared)] });
      
        track("tengu_stop_hook_removed", { via: "goal" });
        return cleared;
      }

7. 运行期:Stop Hook 反馈回路(cli.js L418198–418265)

这是“keep working until …“真正发挥作用的位置。下面把主 query 循环里和 goal 相关的两条路径单独抽出来:

展开查看实现片段(61 行)
// 主循环消费 hook 产生的 message stream
      for await (const frame of hookStream) {
        // 帮助函数:把 frame 上的 hook 描述符映射回当前注册表里的那条 hook
        // (Z = (b) => { … },对应 cli.js L418182)
        const matchedHook = (frame.hook && hookStillRegistered(frame.hook)) ? frame.hook : undefined;
      
        // ── 成功路径:hook 没阻塞 → 视为 goal 达成 ──────────────────────────────
        if (frame.message?.type === "attachment") {
          const att = frame.message.attachment;
          const isStop = att.hookEvent === "Stop" || att.hookEvent === "SubagentStop";
      
          if (isStop && att.type === "hook_success" && matchedHook?.type === "prompt") {
            ctx.sessionHooksRegistry.remove(getSessionId(), "Stop", matchedHook);
            const cur = ctx.getAppState().activeGoal;
      
            if (cur?.condition === matchedHook.prompt) {
              const iterations = cur.iterations + 1;
              const durationMs = Date.now() - cur.setAt;
              const tokens     = getOutputTokenCount() - cur.tokensAtStart;
      
              yield { type: "active_goal", value: undefined };       // 清状态
              yield wrapAttachmentAsMessage({
                type: "goal_status",
                met: true,
                condition: matchedHook.prompt,
                reason: frame.stopReason,
                iterations, durationMs, tokens,
              });
      
              track("tengu_goal_achieved", {
                promptLength: matchedHook.prompt.length, iterations, durationMs, tokens,
              });
              showToast("goal_met");
            }
          }
        }
      
        // ── 失败路径:hook 阻塞 → 未达成,把 reason 反馈给模型继续干活 ──────────
        if (frame.blockingError) {
          // 这条 meta 消息会作为系统提示注入下一轮 LLM 调用
          const feedback = makeUserMessage({
            content: formatBlockingError(frame.blockingError),   // "Stop hook feedback:\n<reason>"
            isMeta: true,
          });
          yield feedback;
      
          const cur = ctx.getAppState().activeGoal;
          if (matchedHook?.type === "prompt" && cur?.condition === matchedHook.prompt) {
            yield {
              type: "active_goal",
              value: { ...cur, iterations: cur.iterations + 1, lastReason: frame.stopReason },
            };
            yield wrapAttachmentAsMessage({
              type: "goal_status",
              met: false,
              condition: matchedHook.prompt,
              reason: frame.stopReason,
            });
          }
        }
      }

关键依赖(再次强调,§3.3 已列出):你的 Stop hook 子系统必须支持 type: "prompt" —— 框架要用一次小 LLM 调用判定该 prompt 是否成立,并据此选择“放行 (hook_success)“还是“阻塞 (blockingError)”。如果你的项目目前只支持 shell/function 型 hook,这是必须先补齐的前置工作


会话恢复与目标生命周期

resume 时通过最近的未完成 sentinel 恢复 active goal;成功、清除或找不到锚点时回到无目标状态。

new session

/goal condition

Stop hook blocks stop

feedback appended

condition met

hook removed

/goal clear

session saved

resume

latest unmet sentinel found

no active sentinel

NoGoal

ActiveGoal

BlockingTurn

Completed

Cleared

PersistedTranscript

ResumeScan

Mermaid state diagram 已渲染为静态 SVG,展示 set、block、resume、clear、complete 的状态转移。

8. 会话恢复:resume 时把 goal 重新挂回去(cli.js L601434–601456)

只在 transcript 末尾找到未完成goal_status 才会重建;否则清空。

展开查看实现片段(39 行)
// 找最近一条未达成的 goal 锚点(含 sentinel 与失败反馈)
      export function findGoalToRestore /* _mK */ (messages: Message[] | undefined): string | null {
        if (!messages) return null;
        for (let i = messages.length - 1; i >= 0; i--) {
          const m = messages[i];
          if (m?.type !== "attachment" || m.attachment.type !== "goal_status") continue;
          return m.attachment.met ? null : m.attachment.condition;
        }
        return null;
      }
      
      export function restoreGoalFromTranscript /* Rg3 */ (
        messages: Message[],
        setAppState: AppStateSetter,
      ): void {
        const condition = findGoalToRestore(messages);
        const gate = condition !== null ? checkGoalGate() : null;
        if (gate !== null) trackWithCode("goal_set", gate.code);
      
        if (condition === null || gate !== null) {
          setAppState(s => (s.activeGoal === undefined ? s : { ...s, activeGoal: undefined }));
          return;
        }
      
        installHook(setAppState, getSessionId(), "Stop", "",
                    { type: "prompt", prompt: condition });
      
        setAppState(s => ({
          ...s,
          activeGoal: {
            condition,
            iterations: 0,
            setAt: Date.now(),
            tokensAtStart: getOutputTokenCount(),
          },
        }));
      
        track("tengu_goal_restored_on_resume", { promptLength: condition.length });
      }

9. 端到端时序

展开查看端到端时序
user> /goal write a passing test for foo.ts
        └─ setGoal(cond, ctx)
             ├─ checkGoalGate() OK
             ├─ sessionHooksRegistry.add(sid, "Stop", "", { type:"prompt", prompt: cond })
             ├─ setAppState: activeGoal = { cond, iter:0, setAt, tokensAtStart }
             ├─ 追加 sentinel attachment(met:false)
             └─ telemetry tengu_stop_hook_added
        └─ 立刻触发 query,prompt = buildGoalDirective(cond)
             └─ 模型:"Acknowledged. Starting now…" + 工具调用…

      模型尝试结束 turn
        └─ Stop hook 评估 prompt
             ├─ met=false → blockingError(reason)
             │    ├─ yield meta 消息 "Stop hook feedback:\n<reason>"
             │    ├─ activeGoal.iterations++, lastReason=reason
             │    ├─ yield goal_status{met:false, reason}
             │    └─ 模型据此继续干活
             └─ met=true  → hook_success
                  ├─ sessionHooksRegistry.remove(...)
                  ├─ activeGoal = undefined
                  ├─ yield goal_status{met:true, iterations, durationMs, tokens}
                  ├─ telemetry tengu_goal_achieved
                  └─ toast goal_met

      user> /goal clear
        └─ clearGoal(ctx) → 移除 hook + 清 activeGoal + 追加 sentinel(met:true)

10. 已知坑 / 校正记录

  1. getOutputTokenCount 仅是 output tokens —— cur.tokensAtStartgoal_status.tokens 都是“自 goal 设置以来的累计 output tokens“,不要把它当 context 占用看。
  2. isNonInteractive() ≠ remote client —— 它就是 !g_.isInteractive。命令显示/启用规则因此是:非交互模式默认显示;非交互或远程工作区时启用。
  3. trust gate 的语义 —— 注意 checkGoalGate!isNonInteractive() && !isWorkspaceTrusted() 才挡,即“交互模式下且工作区未信任“时才挡 /goal;非交互模式不受 trust 限制。
  4. /goal 与其它 Stop hook 共存 —— listGoalStopHooksmatcher === "" && skillRoot === undefined && type === "prompt" 区分用户态 goal,不会清掉 settings 注入的 Stop hook。复刻时这个组合不可改
  5. sentinel 必须存进 transcript —— resume 完全靠 findGoalToRestore 倒序扫描 goal_status 附件来决定要不要重挂 hook。

11. 移植到其它项目的最小落地清单

按依赖顺序:

  1. Stop / SubagentStop hook 子系统(最重要):
    • 注册表 add/remove(支持 matcher + skillRoot 维度)。
    • 支持 { type: "prompt", prompt } hook —— 触发时用一次小 LLM 调用判定该 prompt 是否成立;判定结果转成 hook_successblockingError
    • 主 query 循环消费这些结果,并把 blocking 反馈作为下一轮系统提示。
  2. AppState 字段 activeGoal,配套 setAppState/applyMessageOp 入口。
  3. 消息流的 goal_status 附件类型,至少要能持久化进 transcript(不一定要渲染)。
  4. /goal 命令本体(§5 + §6)—— 复制粘贴即可。
  5. 会话 resume 流程(§8)—— 在加载历史 transcript 后调用一次 restoreGoalFromTranscript
  6. 闸门(§6 的 checkGoalGate)—— 即使 MVP 也建议至少接 trust gate。
  7. 遥测/Toast —— 可选,留空 stub 即可。

12. 符号映射速查表

文档中的清晰名 原始混淆名 定义位置
MAX_CONDITION_LENGTH $oH L517487
CLEAR_KEYWORDS l23 L517503
POLICY_GATE_MESSAGE i23 L517493
TRUST_GATE_MESSAGE n23 L517491
buildGoalDirective uD6 L517489
isClearKeyword xD6 L517416
formatLastCheckLine hjK L517434
checkGoalGate Wu8 L517445
listGoalStopHooks mD6 L517437
makeGoalSentinelMessage EjK L517478
setGoal YoH L517450
clearGoal woH L517465
handleGoalText UV3 L540182
findGoalToRestore _mK L601434
restoreGoalFromTranscript Rg3 L601443
getSessionId k_ L2288
getOutputTokenCount Wf L2409
isNonInteractive Z8 L2656
isRemoteWorkspace V8 L3076
isWorkspaceTrusted q3 L149640
isHooksDisabledByPolicy eu L255357
track Q L3256
trackWithCode j6 L43254
showToast yH L43248
pluralize x6 L9966
firstLine s5 L10026
formatDuration r7 L10804
formatTokenCount F4 L10844
getHooksForEvent twH L376651
installHook iD_ L376600
wrapAttachmentAsMessage z9 L423101
makeUserMessage $6 L450191
formatBlockingError oV8 L554731