源文件:
2.1.139-unbundled/extracted/src/entrypoints/cli.js复刻目标:让 Agent 持续工作直到条件满足。
实现原理一句话:
/goal <cond>把cond注册成一个会话级的Stophook(type: "prompt")。Agent 每次想结束 turn 时这个 hook 会被评估;不满足就把模型的 stop reason 作为反馈塞回去继续干活,满足后自动清除。命名约定:每个标识符首次出现时附
/* 原始混淆名 */注释,方便回溯原始 bundle。
- 先看闭环图和每节开头结论,建立整体模型。
- 正文默认只展示关键片段;长源码放进折叠块,用作验证证据。
- 实现时重点关注 set/clear、Stop hook 反馈、resume 恢复三条路径。
/goal 的 Stop hook 闭环
从用户设置目标,到 Stop hook 拦截结束,再到条件达成后自动清理。
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 L24093.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 L2553573.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 L5547313.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_added、tengu_stop_hook_removed、tengu_goal_achieved、tengu_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;成功、清除或找不到锚点时回到无目标状态。
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. 已知坑 / 校正记录
getOutputTokenCount仅是 output tokens ——cur.tokensAtStart和goal_status.tokens都是“自 goal 设置以来的累计 output tokens“,不要把它当 context 占用看。isNonInteractive()≠ remote client —— 它就是!g_.isInteractive。命令显示/启用规则因此是:非交互模式默认显示;非交互或远程工作区时启用。- trust gate 的语义 —— 注意
checkGoalGate是!isNonInteractive() && !isWorkspaceTrusted()才挡,即“交互模式下且工作区未信任“时才挡/goal;非交互模式不受 trust 限制。 /goal与其它 Stop hook 共存 ——listGoalStopHooks用matcher === "" && skillRoot === undefined && type === "prompt"区分用户态 goal,不会清掉 settings 注入的 Stop hook。复刻时这个组合不可改。- sentinel 必须存进 transcript —— resume 完全靠
findGoalToRestore倒序扫描goal_status附件来决定要不要重挂 hook。
11. 移植到其它项目的最小落地清单
按依赖顺序:
- Stop / SubagentStop hook 子系统(最重要):
- 注册表
add/remove(支持matcher+skillRoot维度)。 - 支持
{ type: "prompt", prompt }hook —— 触发时用一次小 LLM 调用判定该 prompt 是否成立;判定结果转成hook_success或blockingError。 - 主 query 循环消费这些结果,并把 blocking 反馈作为下一轮系统提示。
- 注册表
- AppState 字段
activeGoal,配套setAppState/applyMessageOp入口。 - 消息流的
goal_status附件类型,至少要能持久化进 transcript(不一定要渲染)。 /goal命令本体(§5 + §6)—— 复制粘贴即可。- 会话 resume 流程(§8)—— 在加载历史 transcript 后调用一次
restoreGoalFromTranscript。 - 闸门(§6 的
checkGoalGate)—— 即使 MVP 也建议至少接 trust gate。 - 遥测/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 |