helloagents 3.0.7 → 3.0.9-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +6 -6
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/README.md +65 -58
  5. package/README_CN.md +60 -53
  6. package/bootstrap-lite.md +46 -28
  7. package/bootstrap.md +51 -34
  8. package/gemini-extension.json +1 -1
  9. package/package.json +12 -2
  10. package/scripts/capability-registry.mjs +9 -9
  11. package/scripts/cli-codex-config.mjs +49 -55
  12. package/scripts/cli-codex.mjs +69 -77
  13. package/scripts/cli-doctor.mjs +26 -18
  14. package/scripts/cli-host-detect.mjs +18 -2
  15. package/scripts/cli-messages.mjs +1 -1
  16. package/scripts/cli-toml.mjs +30 -0
  17. package/scripts/delivery-gate.mjs +5 -4
  18. package/scripts/guard-rules.mjs +26 -1
  19. package/scripts/guard.mjs +43 -14
  20. package/scripts/notify-context.mjs +30 -33
  21. package/scripts/notify-route.mjs +5 -2
  22. package/scripts/notify-source.mjs +3 -60
  23. package/scripts/notify.mjs +43 -11
  24. package/scripts/project-storage.mjs +107 -15
  25. package/scripts/session-token.mjs +73 -0
  26. package/scripts/turn-state.mjs +173 -0
  27. package/scripts/workflow-core.mjs +19 -11
  28. package/scripts/workflow-plan-files.mjs +17 -6
  29. package/scripts/workflow-recommendation.mjs +14 -14
  30. package/scripts/workflow-state.mjs +14 -14
  31. package/skills/_meta/SKILL.md +1 -1
  32. package/skills/commands/auto/SKILL.md +24 -9
  33. package/skills/commands/build/SKILL.md +4 -4
  34. package/skills/commands/clean/SKILL.md +3 -3
  35. package/skills/commands/commit/SKILL.md +1 -1
  36. package/skills/commands/help/SKILL.md +3 -3
  37. package/skills/commands/idea/SKILL.md +2 -2
  38. package/skills/commands/init/SKILL.md +13 -8
  39. package/skills/commands/loop/SKILL.md +4 -4
  40. package/skills/commands/plan/SKILL.md +13 -11
  41. package/skills/commands/prd/SKILL.md +10 -8
  42. package/skills/commands/verify/SKILL.md +5 -5
  43. package/skills/commands/wiki/SKILL.md +9 -11
  44. package/skills/hello-review/SKILL.md +1 -1
  45. package/skills/hello-subagent/SKILL.md +3 -2
  46. package/skills/hello-ui/SKILL.md +13 -13
  47. package/skills/hello-verify/SKILL.md +6 -5
  48. package/skills/helloagents/SKILL.md +17 -12
@@ -1,7 +1,7 @@
1
1
  import { join } from 'node:path';
2
2
  import { existsSync, readFileSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
- import { buildCommandRouteHint, buildStateSyncHint, buildWorkflowRouteHint } from './workflow-state.mjs';
4
+ import { buildCommandRouteHint, buildStateSyncHint, buildWorkflowRouteHint, readStateSnapshot } from './workflow-state.mjs';
5
5
  import { buildCapabilityHint } from './capability-registry.mjs';
6
6
  import {
7
7
  buildProjectStorageBlock,
@@ -31,11 +31,6 @@ function resolveStandbyHostRoot(host) {
31
31
  }
32
32
 
33
33
  function resolveReadRoot({ cwd, pkgRoot, host, settings }) {
34
- const projectRoot = join(cwd, 'skills', 'helloagents');
35
- if (existsSync(projectRoot)) {
36
- return { source: 'project', root: projectRoot };
37
- }
38
-
39
34
  if (settings.install_mode === 'standby') {
40
35
  const standbyRoot = resolveStandbyHostRoot(host);
41
36
  if (standbyRoot && existsSync(standbyRoot)) {
@@ -48,7 +43,7 @@ function resolveReadRoot({ cwd, pkgRoot, host, settings }) {
48
43
 
49
44
  function buildReadRootBlock(readRoot) {
50
45
  if (!readRoot?.root) return '';
51
- return `## 当前 HelloAGENTS 读取根目录\n\`\`\`json\n${JSON.stringify(readRoot, null, 2)}\n\`\`\``;
46
+ return `## 本轮 HelloAGENTS 读取根目录\n\`\`\`json\n${JSON.stringify(readRoot, null, 2)}\n\`\`\``;
52
47
  }
53
48
 
54
49
  export function resolveCanonicalCommandSkill(skillName) {
@@ -74,16 +69,14 @@ export function buildCompactionContext({ payload, pkgRoot, settings, bootstrapFi
74
69
  summaryParts.push('以下信息在上下文压缩前保存,确保压缩后不丢失关键状态。');
75
70
 
76
71
  const cwd = payload.cwd || process.cwd();
77
- const statePath = join(cwd, '.helloagents', 'STATE.md');
78
- const stateSyncHint = buildStateSyncHint(cwd);
79
- if (existsSync(statePath)) {
80
- try {
81
- const stateContent = readFileSync(statePath, 'utf-8');
82
- summaryParts.push('');
83
- summaryParts.push('## 恢复快照(从 STATE.md 读取,只用于找回上次停在哪)');
84
- summaryParts.push('恢复时先看当前用户消息,确认仍是同一任务再按 STATE.md 接续。');
85
- summaryParts.push(stateContent);
86
- } catch {}
72
+ const workflowOptions = { payload };
73
+ const stateSnapshot = readStateSnapshot(cwd, workflowOptions);
74
+ const stateSyncHint = buildStateSyncHint(cwd, workflowOptions);
75
+ if (stateSnapshot.exists && stateSnapshot.content) {
76
+ summaryParts.push('');
77
+ summaryParts.push(`## 恢复快照(从 ${stateSnapshot.statePath.replace(/\\/g, '/')} 读取,只用于找回上次停在哪)`);
78
+ summaryParts.push('恢复时先看当前用户消息,确认仍是同一任务再按 STATE.md 接续。');
79
+ summaryParts.push(stateSnapshot.content);
87
80
  }
88
81
 
89
82
  let bootstrap = '';
@@ -108,7 +101,7 @@ export function buildCompactionContext({ payload, pkgRoot, settings, bootstrapFi
108
101
  summaryParts.push(readRootBlock);
109
102
  }
110
103
 
111
- const projectStorageBlock = buildProjectStorageBlock(cwd);
104
+ const projectStorageBlock = buildProjectStorageBlock(cwd, workflowOptions);
112
105
  if (projectStorageBlock) {
113
106
  summaryParts.push('');
114
107
  summaryParts.push(projectStorageBlock);
@@ -128,13 +121,15 @@ export function buildCompactionContext({ payload, pkgRoot, settings, bootstrapFi
128
121
  return summaryParts.join('\n');
129
122
  }
130
123
 
131
- export function buildInjectContext({ source, bootstrap, settings, pkgRoot, host, cwd }) {
124
+ export function buildInjectContext({ source, bootstrap, settings, pkgRoot, host, cwd, payload = {} }) {
125
+ const workflowOptions = { payload };
132
126
  const packageRootBlock = buildPackageRootBlock(pkgRoot);
133
127
  const readRootBlock = buildReadRootBlock(resolveReadRoot({ cwd, pkgRoot, host, settings }));
134
- const workflowHint = buildWorkflowRouteHint(cwd);
135
- const capabilityHint = buildCapabilityHint({ cwd });
136
- const projectStorageBlock = buildProjectStorageBlock(cwd);
137
- const stateSyncHint = buildStateSyncHint(cwd);
128
+ const workflowHint = buildWorkflowRouteHint(cwd, workflowOptions);
129
+ const capabilityHint = buildCapabilityHint({ cwd, options: workflowOptions });
130
+ const projectStorageBlock = buildProjectStorageBlock(cwd, workflowOptions);
131
+ const stateSnapshot = readStateSnapshot(cwd, workflowOptions);
132
+ const stateSyncHint = buildStateSyncHint(cwd, workflowOptions);
138
133
  const settingsBlock = Object.keys(settings).length
139
134
  ? `\n\n## 当前用户设置\n\`\`\`json\n${JSON.stringify(settings, null, 2)}\n\`\`\``
140
135
  : '';
@@ -148,31 +143,33 @@ export function buildInjectContext({ source, bootstrap, settings, pkgRoot, host,
148
143
  if (stateSyncHint) context += `\n\n## STATE.md 提醒\n${stateSyncHint}`;
149
144
  context += settingsBlock;
150
145
  if (source === 'resume' || source === 'compact') {
151
- context += '\n\n> ⚠️ 会话已恢复/压缩,请先读取 `.helloagents/STATE.md` 恢复工作状态;先看当前用户消息确认仍是同一任务,再按 STATE.md 接续。';
146
+ context += `\n\n> ⚠️ 会话已恢复/压缩,请先读取当前 \`state_path\` 指向的 \`${stateSnapshot.statePath.replace(/\\/g, '/')}\` 恢复工作状态;先看当前用户消息确认仍是同一任务,再按 STATE.md 接续。`;
152
147
  }
153
148
  return context;
154
149
  }
155
150
 
156
- export function buildRouteInstruction({ skillName, extraRules = '', cwd, pkgRoot, host, settings }) {
151
+ export function buildRouteInstruction({ skillName, extraRules = '', cwd, pkgRoot, host, settings, payload = {} }) {
152
+ const workflowOptions = { payload };
157
153
  const readRoot = resolveReadRoot({ cwd, pkgRoot, host, settings });
158
154
  const canonicalSkillName = resolveCanonicalCommandSkill(skillName);
159
155
  const skillPath = join(readRoot.root, 'skills', 'commands', canonicalSkillName, 'SKILL.md');
160
156
  const aliasNote = buildAliasRouteNote(skillName);
161
- const commandHint = buildCommandRouteHint(canonicalSkillName, cwd);
162
- const capabilityHint = buildCapabilityHint({ cwd, skillName: canonicalSkillName });
163
- const projectStorageHint = buildProjectStorageHint(cwd);
157
+ const commandHint = buildCommandRouteHint(canonicalSkillName, cwd, workflowOptions);
158
+ const capabilityHint = buildCapabilityHint({ cwd, skillName: canonicalSkillName, options: workflowOptions });
159
+ const projectStorageHint = buildProjectStorageHint(cwd, workflowOptions);
164
160
  return `用户使用了 ~${skillName} 命令。当前命令技能文件已解析为:${skillPath}。请直接读取这个 SKILL.md;不要再探测其他 helloagents 路径。${aliasNote ? ` ${aliasNote}` : ''}${projectStorageHint ? ` ${projectStorageHint}` : ''}${commandHint ? ` ${commandHint}` : ''}${capabilityHint ? ` ${capabilityHint}` : ''}${extraRules}`;
165
161
  }
166
162
 
167
- export function buildSemanticRouteInstruction(cwd) {
168
- const workflowHint = buildWorkflowRouteHint(cwd);
169
- const capabilityHint = buildCapabilityHint({ cwd });
170
- const projectStorageHint = buildProjectStorageHint(cwd);
163
+ export function buildSemanticRouteInstruction(cwd, payload = {}) {
164
+ const workflowOptions = { payload };
165
+ const workflowHint = buildWorkflowRouteHint(cwd, workflowOptions);
166
+ const capabilityHint = buildCapabilityHint({ cwd, options: workflowOptions });
167
+ const projectStorageHint = buildProjectStorageHint(cwd, workflowOptions);
171
168
  return [
172
169
  '当前消息未使用 ~command。',
173
170
  '请根据用户请求的真实意图选路,不依赖关键词表。',
174
171
  'Delivery Tier: T0=探索/比较;T1=低风险小改动或显式验证;T2=多文件功能/新项目/需要结构化产物;T3=高风险或不可逆链路。',
175
- '路由映射:~idea=只读探索,不创建文件;~build=明确实现;~verify=审查/验证;~plan=结构化规划;~prd=重型规格;~auto=自动选路。',
172
+ '路由映射:~idea=只读探索,不创建文件;~build=明确实现;~verify=审查/验证;~plan=结构化规划;~prd=重型规格;~auto=自动编排并自动衔接后续阶段。',
176
173
  '若判定为 T3,默认先走 ~plan / ~prd;纯审查/验证请求才优先 ~verify。',
177
174
  `涉及 UI 任务时,设计决策优先级:当前活跃 plan / PRD → ${describeProjectStoreFile(cwd, 'DESIGN.md')} → 通用 UI 规则。`,
178
175
  projectStorageHint,
@@ -17,6 +17,7 @@ function buildHelpExtraRules(skillName) {
17
17
 
18
18
  function routeExplicitCommand({
19
19
  prompt,
20
+ payload,
20
21
  cwd,
21
22
  host,
22
23
  pkgRoot,
@@ -51,6 +52,7 @@ function routeExplicitCommand({
51
52
  pkgRoot,
52
53
  host,
53
54
  settings,
55
+ payload,
54
56
  }))
55
57
  return true
56
58
  }
@@ -80,6 +82,7 @@ export function handleRouteCommand({
80
82
 
81
83
  if (routeExplicitCommand({
82
84
  prompt,
85
+ payload,
83
86
  cwd,
84
87
  host,
85
88
  pkgRoot,
@@ -100,9 +103,9 @@ export function handleRouteCommand({
100
103
  host,
101
104
  event: 'semantic_route_prompted',
102
105
  source: 'route',
103
- recommendation: getWorkflowRecommendation(cwd),
106
+ recommendation: getWorkflowRecommendation(cwd, { payload }),
104
107
  })
105
- suppress(buildSemanticRouteInstruction(cwd))
108
+ suppress(buildSemanticRouteInstruction(cwd, payload))
106
109
  return
107
110
  }
108
111
 
@@ -1,42 +1,13 @@
1
1
  import { basename, normalize, resolve } from 'node:path'
2
2
 
3
+ import { resolveSessionToken } from './session-token.mjs'
4
+
3
5
  const HOST_LABELS = {
4
6
  codex: 'Codex',
5
7
  claude: 'Claude Code',
6
8
  gemini: 'Gemini',
7
9
  }
8
10
 
9
- const PAYLOAD_SESSION_KEYS = [
10
- 'sessionId',
11
- 'session_id',
12
- 'session',
13
- 'conversationId',
14
- 'conversation_id',
15
- 'conversation',
16
- 'threadId',
17
- 'thread_id',
18
- 'thread',
19
- 'windowId',
20
- 'window_id',
21
- 'window',
22
- 'tabId',
23
- 'tab_id',
24
- 'tab',
25
- 'requestId',
26
- 'request_id',
27
- ]
28
-
29
- const ENV_SESSION_KEYS = [
30
- 'HELLOAGENTS_NOTIFY_SESSION_ID',
31
- 'WT_SESSION',
32
- 'TERM_SESSION_ID',
33
- 'KITTY_WINDOW_ID',
34
- 'ALACRITTY_WINDOW_ID',
35
- 'WINDOWID',
36
- 'WEZTERM_PANE',
37
- 'TAB_ID',
38
- ]
39
-
40
11
  function normalizePath(filePath = '') {
41
12
  if (!filePath) return ''
42
13
  try {
@@ -61,34 +32,6 @@ function resolveProjectLabel(cwd = '') {
61
32
  return label || normalized.replace(/\\/g, '/')
62
33
  }
63
34
 
64
- function sanitizeSessionToken(value = '') {
65
- const raw = String(value).trim().replace(/^[#:\s]+/, '')
66
- const segments = raw
67
- .split(/[^a-zA-Z0-9]+/)
68
- .filter(Boolean)
69
- const cleaned = segments.length > 1
70
- ? segments[segments.length - 1]
71
- : raw.replace(/[^a-zA-Z0-9_-]/g, '')
72
-
73
- if (!cleaned) return ''
74
- if (/^\d+$/.test(cleaned)) return cleaned
75
- return cleaned.slice(0, 8)
76
- }
77
-
78
- function resolveSessionToken(payload, env, ppid) {
79
- for (const key of PAYLOAD_SESSION_KEYS) {
80
- const value = sanitizeSessionToken(readStringCandidate(payload, key))
81
- if (value) return value
82
- }
83
-
84
- for (const key of ENV_SESSION_KEYS) {
85
- const value = sanitizeSessionToken(env?.[key] || '')
86
- if (value) return value
87
- }
88
-
89
- return ppid ? String(ppid) : ''
90
- }
91
-
92
35
  export function resolveNotificationSource({
93
36
  host = '',
94
37
  cwd = '',
@@ -98,7 +41,7 @@ export function resolveNotificationSource({
98
41
  } = {}) {
99
42
  const hostLabel = HOST_LABELS[host] || 'Agent'
100
43
  const projectLabel = resolveProjectLabel(readStringCandidate(payload, 'cwd') || cwd)
101
- const sessionToken = resolveSessionToken(payload, env, ppid)
44
+ const sessionToken = resolveSessionToken({ payload, env, ppid })
102
45
  const parts = [hostLabel]
103
46
 
104
47
  if (projectLabel) parts.push(projectLabel)
@@ -10,11 +10,12 @@ import { homedir } from 'node:os';
10
10
  import { playSound as _playSound, desktopNotify as _desktopNotify } from './notify-ui.mjs';
11
11
  import { resolveNotificationSource } from './notify-source.mjs';
12
12
  import { buildCompactionContext, buildInjectContext, buildRouteInstruction, buildSemanticRouteInstruction, resolveCanonicalCommandSkill } from './notify-context.mjs';
13
- import { claimsTaskComplete, shouldIgnoreCodexNotifyClient, shouldIgnoreFormattedSubagent } from './notify-events.mjs';
13
+ import { claimsTaskComplete, shouldIgnoreCodexNotifyClient } from './notify-events.mjs';
14
14
  import { handleRouteCommand, resolveBootstrapFile } from './notify-route.mjs';
15
15
  import { readSettings, readStdinJson, output, suppressedOutput, emptySuppress } from './notify-shared.mjs';
16
16
  import { clearRouteContext, writeRouteContext } from './runtime-context.mjs';
17
17
  import { appendReplayEvent, startReplaySession } from './replay-state.mjs';
18
+ import { clearTurnState, readTurnState } from './turn-state.mjs';
18
19
  import { getWorkflowRecommendation } from './workflow-state.mjs';
19
20
 
20
21
  const __filename = fileURLToPath(import.meta.url);
@@ -114,6 +115,20 @@ function readCompletionText(payload = {}) {
114
115
  || '';
115
116
  }
116
117
 
118
+ function readMainTurnState(cwd) {
119
+ const turnState = readTurnState(cwd);
120
+ return turnState?.role === 'main' ? turnState : null;
121
+ }
122
+
123
+ function consumeMainTurnState(cwd, turnState) {
124
+ if (turnState?.role === 'main') clearTurnState(cwd);
125
+ }
126
+
127
+ function shouldProcessCloseout(turnState, lastMsg) {
128
+ if (turnState) return turnState.kind === 'complete';
129
+ return claimsTaskComplete(lastMsg);
130
+ }
131
+
117
132
  function cmdPreCompact() {
118
133
  const payload = readStdinJson();
119
134
  const cwd = payload.cwd || process.cwd();
@@ -140,6 +155,7 @@ function cmdPreCompact() {
140
155
 
141
156
  function cmdRoute() {
142
157
  const payload = readStdinJson();
158
+ clearTurnState(payload.cwd || process.cwd());
143
159
  handleRouteCommand({
144
160
  payload,
145
161
  host: HOST,
@@ -192,8 +208,10 @@ function cmdInject() {
192
208
  pkgRoot: PKG_ROOT,
193
209
  host: HOST,
194
210
  cwd,
211
+ payload,
195
212
  });
196
213
  clearRouteContext();
214
+ clearTurnState(cwd);
197
215
  suppressedOutput(EVENT_NAME.SessionStart, context || undefined);
198
216
  }
199
217
 
@@ -201,13 +219,17 @@ function cmdStop() {
201
219
  const payload = readStdinJson();
202
220
  const lastMsg = readCompletionText(payload);
203
221
  const cwd = payload.cwd || process.cwd();
222
+ const turnState = readMainTurnState(cwd);
223
+ const shouldProcess = shouldProcessCloseout(turnState, lastMsg);
204
224
  clearRouteContext();
205
- if (runRalphLoop(payload)) {
225
+ if (shouldProcess && runRalphLoop(payload)) {
226
+ consumeMainTurnState(cwd, turnState);
206
227
  playSound('warning');
207
228
  desktopNotify('warning', buildNotifyExtra(payload));
208
229
  return;
209
230
  }
210
- if (claimsTaskComplete(lastMsg) && runDeliveryGate(payload)) {
231
+ if (shouldProcess && runDeliveryGate(payload)) {
232
+ consumeMainTurnState(cwd, turnState);
211
233
  playSound('warning');
212
234
  desktopNotify('warning', buildNotifyExtra(payload));
213
235
  return;
@@ -215,8 +237,11 @@ function cmdStop() {
215
237
 
216
238
  const settings = getSettings();
217
239
  const level = settings.notify_level ?? 0;
218
- if (level === 2 || level === 3) playSound('complete');
219
- if (level === 1 || level === 3) desktopNotify('complete', buildNotifyExtra(payload));
240
+ if (shouldProcess) {
241
+ if (level === 2 || level === 3) playSound('complete');
242
+ if (level === 1 || level === 3) desktopNotify('complete', buildNotifyExtra(payload));
243
+ }
244
+ consumeMainTurnState(cwd, turnState);
220
245
  emptySuppress();
221
246
  }
222
247
 
@@ -243,17 +268,23 @@ function cmdCodexNotify() {
243
268
  }
244
269
  if (type !== 'agent-turn-complete') return;
245
270
 
246
- const lastMsg = data['last-assistant-message'] || '';
247
- const settings = getSettings();
248
- if (shouldIgnoreFormattedSubagent(lastMsg, settings.output_format !== false)) return;
249
-
250
271
  const cwd = data.cwd || process.cwd();
251
- if (claimsTaskComplete(lastMsg) && runRalphLoop({ cwd })) {
272
+ const turnState = readMainTurnState(cwd);
273
+ if (!turnState) return;
274
+ if (turnState.kind !== 'complete') {
275
+ consumeMainTurnState(cwd, turnState);
276
+ return;
277
+ }
278
+
279
+ const settings = getSettings();
280
+ if (runRalphLoop(data)) {
281
+ consumeMainTurnState(cwd, turnState);
252
282
  playSound('warning');
253
283
  desktopNotify('warning', buildNotifyExtra(data));
254
284
  return;
255
285
  }
256
- if (claimsTaskComplete(lastMsg) && runDeliveryGate({ cwd })) {
286
+ if (runDeliveryGate(data)) {
287
+ consumeMainTurnState(cwd, turnState);
257
288
  playSound('warning');
258
289
  desktopNotify('warning', buildNotifyExtra(data));
259
290
  return;
@@ -262,6 +293,7 @@ function cmdCodexNotify() {
262
293
  const level = settings.notify_level ?? 0;
263
294
  if (level === 2 || level === 3) playSound('complete');
264
295
  if (level === 1 || level === 3) desktopNotify('complete', buildNotifyExtra(data));
296
+ consumeMainTurnState(cwd, turnState);
265
297
  }
266
298
 
267
299
  const cmd = process.argv[2] || '';
@@ -5,10 +5,13 @@ import { homedir } from 'node:os'
5
5
  import { basename, dirname, isAbsolute, join, normalize, resolve } from 'node:path'
6
6
 
7
7
  import { DEFAULTS } from './cli-config.mjs'
8
+ import { resolveSessionToken } from './session-token.mjs'
8
9
 
9
10
  export const PROJECT_DIR_NAME = '.helloagents'
10
11
  const PROJECTS_DIR_NAME = 'projects'
12
+ const PROJECT_SESSIONS_DIR_NAME = 'sessions'
11
13
  const PROJECT_STORE_MODES = new Set(['local', 'repo-shared'])
14
+ const DEFAULT_STATE_SESSION_TOKEN = 'default'
12
15
 
13
16
  function safeJson(filePath) {
14
17
  try {
@@ -31,6 +34,19 @@ function runGitRevParse(cwd, args = []) {
31
34
  }
32
35
  }
33
36
 
37
+ function runGitCommand(cwd, args = []) {
38
+ try {
39
+ return execFileSync('git', args, {
40
+ cwd,
41
+ encoding: 'utf-8',
42
+ timeout: 5_000,
43
+ stdio: ['ignore', 'pipe', 'ignore'],
44
+ }).trim()
45
+ } catch {
46
+ return ''
47
+ }
48
+ }
49
+
34
50
  function resolveGitTopLevel(cwd) {
35
51
  const absolute = runGitRevParse(cwd, ['--path-format=absolute', '--show-toplevel'])
36
52
  if (absolute) return normalize(absolute)
@@ -54,6 +70,16 @@ function sanitizeRepoName(value = '') {
54
70
  return normalized || 'project'
55
71
  }
56
72
 
73
+ function sanitizeStateScopeSegment(value = '', fallback = '') {
74
+ const normalized = String(value)
75
+ .trim()
76
+ .toLowerCase()
77
+ .replace(/[^a-z0-9._-]+/g, '-')
78
+ .replace(/^-+|-+$/g, '')
79
+ .slice(0, 48)
80
+ return normalized || fallback
81
+ }
82
+
57
83
  function buildProjectKey(cwd) {
58
84
  const repoRoot = resolveGitTopLevel(cwd)
59
85
  const commonDir = resolveGitCommonDir(cwd, repoRoot)
@@ -87,6 +113,15 @@ function formatPromptPath(pathValue = '') {
87
113
  return pathValue ? normalize(pathValue).replace(/\\/g, '/') : ''
88
114
  }
89
115
 
116
+ function resolveGitBranchName(cwd) {
117
+ const branchName = runGitRevParse(cwd, ['--abbrev-ref', 'HEAD'])
118
+ if (branchName && branchName !== 'HEAD') return branchName
119
+
120
+ const symbolicBranchName = runGitCommand(cwd, ['symbolic-ref', '--quiet', '--short', 'HEAD'])
121
+ if (symbolicBranchName && symbolicBranchName !== 'HEAD') return symbolicBranchName
122
+ return ''
123
+ }
124
+
90
125
  export function normalizeProjectStoreMode(value) {
91
126
  const normalized = typeof value === 'string' ? value.trim().toLowerCase() : ''
92
127
  return PROJECT_STORE_MODES.has(normalized) ? normalized : DEFAULTS.project_store_mode
@@ -105,8 +140,38 @@ export function getProjectActivationDir(cwd) {
105
140
  return join(cwd, PROJECT_DIR_NAME)
106
141
  }
107
142
 
108
- export function getProjectStatePath(cwd) {
109
- return join(getProjectActivationDir(cwd), 'STATE.md')
143
+ export function getProjectSessionStateScope(cwd, {
144
+ payload = {},
145
+ env = process.env,
146
+ ppid = process.ppid,
147
+ } = {}) {
148
+ const rawSessionToken = resolveSessionToken({
149
+ payload,
150
+ env,
151
+ ppid,
152
+ allowPpidFallback: false,
153
+ })
154
+ const branchName = sanitizeStateScopeSegment(resolveGitBranchName(cwd), 'detached')
155
+ const sessionToken = sanitizeStateScopeSegment(rawSessionToken, DEFAULT_STATE_SESSION_TOKEN)
156
+ const sessionDir = join(
157
+ getProjectActivationDir(cwd),
158
+ PROJECT_SESSIONS_DIR_NAME,
159
+ branchName,
160
+ sessionToken,
161
+ )
162
+
163
+ return {
164
+ stateScope: 'session',
165
+ stateSessionToken: sessionToken,
166
+ stateSessionMode: rawSessionToken ? 'host-session' : 'default',
167
+ stateBranch: branchName,
168
+ sessionDir,
169
+ statePath: join(sessionDir, 'STATE.md'),
170
+ }
171
+ }
172
+
173
+ export function getProjectStatePath(cwd, options = {}) {
174
+ return getProjectSessionStateScope(cwd, options).statePath
110
175
  }
111
176
 
112
177
  export function isRepoSharedProjectStore(cwd) {
@@ -122,10 +187,10 @@ export function getProjectStoreDir(cwd) {
122
187
  return join(homedir(), PROJECT_DIR_NAME, PROJECTS_DIR_NAME, projectKey.key)
123
188
  }
124
189
 
125
- export function getProjectStoreSummary(cwd) {
190
+ export function getProjectStoreSummary(cwd, options = {}) {
126
191
  const activationDir = getProjectActivationDir(cwd)
127
192
  const storeDir = getProjectStoreDir(cwd)
128
- const statePath = getProjectStatePath(cwd)
193
+ const stateScope = getProjectSessionStateScope(cwd, options)
129
194
  const projectKey = buildProjectKey(cwd)
130
195
  const projectStoreMode = getProjectStoreMode(cwd)
131
196
 
@@ -133,14 +198,20 @@ export function getProjectStoreSummary(cwd) {
133
198
  projectStoreMode,
134
199
  activationDir,
135
200
  storeDir,
136
- statePath,
201
+ statePath: stateScope.statePath,
202
+ stateScope: stateScope.stateScope,
203
+ stateSessionToken: stateScope.stateSessionToken,
204
+ stateSessionMode: stateScope.stateSessionMode,
205
+ stateBranch: stateScope.stateBranch,
206
+ sessionStateDir: stateScope.sessionDir,
137
207
  usesSharedStore: projectStoreMode === 'repo-shared',
138
208
  projectKey: projectKey.key,
139
209
  repoRoot: projectKey.repoRoot,
140
210
  commonDir: projectKey.commonDir,
141
211
  promptActivationDir: formatPromptPath(activationDir),
142
212
  promptStoreDir: formatPromptPath(storeDir),
143
- promptStatePath: formatPromptPath(statePath),
213
+ promptStatePath: formatPromptPath(stateScope.statePath),
214
+ promptSessionStateDir: formatPromptPath(stateScope.sessionDir),
144
215
  }
145
216
  }
146
217
 
@@ -203,14 +274,21 @@ export function describeProjectStoreFile(cwd, relativePath = '') {
203
274
  return `逻辑路径 \`${logicalPath}\`(实际存储:\`${actualPath}\`)`
204
275
  }
205
276
 
206
- export function buildProjectStorageHint(cwd) {
207
- const summary = getProjectStoreSummary(cwd)
208
- if (!summary.usesSharedStore) return ''
209
- return `项目存储:\`project_store_mode=repo-shared\`;本地激活/运行态目录仍是 \`${summary.promptActivationDir}\`,知识库/方案目录改为 \`${summary.promptStoreDir}\`。`
277
+ export function buildProjectStorageHint(cwd, options = {}) {
278
+ const summary = getProjectStoreSummary(cwd, options)
279
+ const hints = []
280
+ hints.push(`当前恢复快照统一写入 \`${summary.promptStatePath}\``)
281
+ if (summary.stateSessionMode === 'default') {
282
+ hints.push(`当前宿主未提供稳定会话标识,因此落到分支级默认会话槽位 \`${summary.stateSessionToken}\``)
283
+ }
284
+ if (summary.usesSharedStore) {
285
+ hints.push(`项目存储:\`project_store_mode=repo-shared\`;本地激活/运行态目录仍是 \`${summary.promptActivationDir}\`,知识库/方案目录改为 \`${summary.promptStoreDir}\``)
286
+ }
287
+ return hints.join('。') + (hints.length > 0 ? '。' : '')
210
288
  }
211
289
 
212
- export function buildProjectStorageBlock(cwd) {
213
- const summary = getProjectStoreSummary(cwd)
290
+ export function buildProjectStorageBlock(cwd, options = {}) {
291
+ const summary = getProjectStoreSummary(cwd, options)
214
292
  if (!summary.usesSharedStore && !existsSync(summary.activationDir)) {
215
293
  return ''
216
294
  }
@@ -218,18 +296,32 @@ export function buildProjectStorageBlock(cwd) {
218
296
  const details = {
219
297
  project_store_mode: summary.projectStoreMode,
220
298
  activation_dir: summary.promptActivationDir,
299
+ state_scope: summary.stateScope,
221
300
  state_path: summary.promptStatePath,
301
+ state_branch: summary.stateBranch,
302
+ state_session_token: summary.stateSessionToken,
303
+ state_session_mode: summary.stateSessionMode,
304
+ session_state_dir: summary.promptSessionStateDir,
222
305
  knowledge_base_dir: summary.promptStoreDir,
223
306
  uses_shared_store: summary.usesSharedStore,
224
307
  }
225
308
 
309
+ const explanations = []
310
+ explanations.push('说明:恢复快照只认 `state_path` 这一个权威路径,不再读写旧的项目级 `.helloagents/STATE.md`。')
311
+ if (summary.stateSessionMode === 'default') {
312
+ explanations.push('说明:当前宿主未提供稳定会话标识,因此自动使用分支级默认会话槽位,仍保持新目录结构。')
313
+ }
314
+ if (summary.usesSharedStore) {
315
+ explanations.push('说明:`STATE.md` 与 `.ralph-*.json` 继续写本地激活目录;`context.md`、`guidelines.md`、`DESIGN.md`、`verify.yaml`、`modules/`、`plans/`、`archive/` 写知识库/方案目录。')
316
+ } else {
317
+ explanations.push('说明:当前使用项目本地 `.helloagents/` 作为激活目录、知识库目录和方案目录。')
318
+ }
319
+
226
320
  return [
227
321
  '## 当前项目存储',
228
322
  '```json',
229
323
  JSON.stringify(details, null, 2),
230
324
  '```',
231
- summary.usesSharedStore
232
- ? '说明:`STATE.md` 与 `.ralph-*.json` 继续写本地激活目录;`context.md`、`guidelines.md`、`DESIGN.md`、`verify.yaml`、`modules/`、`plans/`、`archive/` 写知识库/方案目录。'
233
- : '说明:当前使用项目本地 `.helloagents/` 作为激活目录、知识库目录和方案目录。',
325
+ ...explanations,
234
326
  ].join('\n')
235
327
  }
@@ -0,0 +1,73 @@
1
+ const PAYLOAD_SESSION_KEYS = [
2
+ 'sessionId',
3
+ 'session_id',
4
+ 'session',
5
+ 'conversationId',
6
+ 'conversation_id',
7
+ 'conversation',
8
+ 'threadId',
9
+ 'thread_id',
10
+ 'thread',
11
+ 'windowId',
12
+ 'window_id',
13
+ 'window',
14
+ 'tabId',
15
+ 'tab_id',
16
+ 'tab',
17
+ 'requestId',
18
+ 'request_id',
19
+ ]
20
+
21
+ const ENV_SESSION_KEYS = [
22
+ 'HELLOAGENTS_NOTIFY_SESSION_ID',
23
+ 'WT_SESSION',
24
+ 'TERM_SESSION_ID',
25
+ 'KITTY_WINDOW_ID',
26
+ 'ALACRITTY_WINDOW_ID',
27
+ 'WINDOWID',
28
+ 'WEZTERM_PANE',
29
+ 'TAB_ID',
30
+ ]
31
+
32
+ function readStringCandidate(input, key) {
33
+ if (!input || typeof input !== 'object') return ''
34
+ const value = input[key]
35
+ if (typeof value === 'string') return value.trim()
36
+ if (typeof value === 'number') return String(value)
37
+ return ''
38
+ }
39
+
40
+ export function sanitizeSessionToken(value = '') {
41
+ const raw = String(value).trim().replace(/^[#:\s]+/, '')
42
+ const segments = raw
43
+ .split(/[^a-zA-Z0-9]+/)
44
+ .filter(Boolean)
45
+ const cleaned = segments.length > 1
46
+ ? segments[segments.length - 1]
47
+ : raw.replace(/[^a-zA-Z0-9_-]/g, '')
48
+
49
+ if (!cleaned) return ''
50
+ if (/^\d+$/.test(cleaned)) return cleaned
51
+ return cleaned.slice(0, 8)
52
+ }
53
+
54
+ export function resolveSessionToken({
55
+ payload = {},
56
+ env = process.env,
57
+ ppid = process.ppid,
58
+ allowPpidFallback = true,
59
+ } = {}) {
60
+ for (const key of PAYLOAD_SESSION_KEYS) {
61
+ const value = sanitizeSessionToken(readStringCandidate(payload, key))
62
+ if (value) return value
63
+ }
64
+
65
+ for (const key of ENV_SESSION_KEYS) {
66
+ const value = sanitizeSessionToken(env?.[key] || '')
67
+ if (value) return value
68
+ }
69
+
70
+ return allowPpidFallback && ppid ? String(ppid) : ''
71
+ }
72
+
73
+ export { ENV_SESSION_KEYS, PAYLOAD_SESSION_KEYS }