helloagents 3.0.8-beta.1 → 3.0.10-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 (49) 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 +18 -9
  5. package/README_CN.md +42 -33
  6. package/bootstrap-lite.md +18 -17
  7. package/bootstrap.md +22 -21
  8. package/gemini-extension.json +1 -1
  9. package/package.json +1 -1
  10. package/scripts/capability-registry.mjs +5 -5
  11. package/scripts/cli-codex-config.mjs +8 -2
  12. package/scripts/cli-codex.mjs +7 -3
  13. package/scripts/cli-doctor.mjs +6 -1
  14. package/scripts/cli-host-detect.mjs +18 -2
  15. package/scripts/cli-lifecycle.mjs +11 -0
  16. package/scripts/cli-messages.mjs +3 -3
  17. package/scripts/cli-toml-values.mjs +25 -0
  18. package/scripts/cli-toml.mjs +15 -0
  19. package/scripts/delivery-gate.mjs +5 -4
  20. package/scripts/guard.mjs +7 -6
  21. package/scripts/notify-context.mjs +32 -30
  22. package/scripts/notify-events.mjs +0 -8
  23. package/scripts/notify-gates.mjs +128 -0
  24. package/scripts/notify-route.mjs +5 -2
  25. package/scripts/notify-source.mjs +3 -60
  26. package/scripts/notify-ui.mjs +3 -0
  27. package/scripts/notify.mjs +72 -83
  28. package/scripts/project-storage.mjs +107 -15
  29. package/scripts/session-token.mjs +73 -0
  30. package/scripts/workflow-core.mjs +16 -8
  31. package/scripts/workflow-plan-files.mjs +17 -6
  32. package/scripts/workflow-recommendation.mjs +4 -4
  33. package/scripts/workflow-state.mjs +13 -13
  34. package/skills/commands/auto/SKILL.md +13 -13
  35. package/skills/commands/build/SKILL.md +5 -5
  36. package/skills/commands/clean/SKILL.md +6 -6
  37. package/skills/commands/commit/SKILL.md +2 -2
  38. package/skills/commands/help/SKILL.md +2 -2
  39. package/skills/commands/idea/SKILL.md +5 -5
  40. package/skills/commands/init/SKILL.md +4 -4
  41. package/skills/commands/loop/SKILL.md +4 -4
  42. package/skills/commands/plan/SKILL.md +13 -13
  43. package/skills/commands/prd/SKILL.md +13 -13
  44. package/skills/commands/verify/SKILL.md +3 -3
  45. package/skills/commands/wiki/SKILL.md +5 -5
  46. package/skills/hello-subagent/SKILL.md +1 -1
  47. package/skills/hello-ui/SKILL.md +3 -3
  48. package/skills/helloagents/SKILL.md +3 -2
  49. package/templates/plans/contract.json +2 -2
@@ -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)
@@ -18,6 +18,7 @@ const NOTIFY_MESSAGES = {
18
18
  };
19
19
 
20
20
  const WIN_APPID = 'HelloAgents.Notification';
21
+ const DISABLE_OS_NOTIFICATIONS = process.env.HELLOAGENTS_DISABLE_OS_NOTIFICATIONS === '1';
21
22
 
22
23
  function escapeToastText(value = '') {
23
24
  return String(value)
@@ -55,6 +56,7 @@ function resolveWav(pkgRoot, event) {
55
56
  }
56
57
 
57
58
  export function playSound(pkgRoot, event) {
59
+ if (DISABLE_OS_NOTIFICATIONS) return;
58
60
  const wav = resolveWav(pkgRoot, event);
59
61
  if (!wav) { process.stderr.write('\x07'); return; }
60
62
  try {
@@ -83,6 +85,7 @@ function ensureWinAppId(pkgRoot) {
83
85
  }
84
86
 
85
87
  export function desktopNotify(pkgRoot, event, extra) {
88
+ if (DISABLE_OS_NOTIFICATIONS) return;
86
89
  const notification = buildDesktopNotificationContent(event, extra);
87
90
  try {
88
91
  if (PLAT === 'win32') {
@@ -4,13 +4,13 @@
4
4
 
5
5
  import { join, dirname } from 'node:path';
6
6
  import { existsSync, readFileSync } from 'node:fs';
7
- import { spawnSync } from 'node:child_process';
8
7
  import { fileURLToPath } from 'node:url';
9
8
  import { homedir } from 'node:os';
10
9
  import { playSound as _playSound, desktopNotify as _desktopNotify } from './notify-ui.mjs';
11
10
  import { resolveNotificationSource } from './notify-source.mjs';
12
11
  import { buildCompactionContext, buildInjectContext, buildRouteInstruction, buildSemanticRouteInstruction, resolveCanonicalCommandSkill } from './notify-context.mjs';
13
- import { claimsTaskComplete, shouldIgnoreCodexNotifyClient } from './notify-events.mjs';
12
+ import { shouldIgnoreCodexNotifyClient } from './notify-events.mjs';
13
+ import { runGateScript } from './notify-gates.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';
@@ -37,6 +37,17 @@ const EVENT_NAME = {
37
37
  const playSound = (event) => _playSound(PKG_ROOT, event);
38
38
  const desktopNotify = (event, extra) => _desktopNotify(PKG_ROOT, event, extra);
39
39
 
40
+ function normalizeNotifyLevel(value) {
41
+ const level = Number(value);
42
+ return [0, 1, 2, 3].includes(level) ? level : 0;
43
+ }
44
+
45
+ function notifyByLevel(event, extra, settings = getSettings()) {
46
+ const level = normalizeNotifyLevel(settings.notify_level ?? 0);
47
+ if (level === 2 || level === 3) playSound(event);
48
+ if (level === 1 || level === 3) desktopNotify(event, extra);
49
+ }
50
+
40
51
  function buildNotifyExtra(payload = {}, options = {}) {
41
52
  const source = resolveNotificationSource({
42
53
  host: HOST,
@@ -56,71 +67,44 @@ function getSettings() {
56
67
  function runRalphLoop(payload) {
57
68
  const settings = getSettings();
58
69
  if (settings.ralph_loop_enabled === false) return false;
59
- try {
60
- const rlPath = join(__dirname, 'ralph-loop.mjs');
61
- if (!existsSync(rlPath)) return false;
62
- const hostFlag = IS_GEMINI ? ['--gemini'] : HOST === 'codex' ? ['--codex'] : [];
63
- const result = spawnSync(process.execPath, [rlPath, ...hostFlag], {
64
- input: JSON.stringify(payload),
65
- encoding: 'utf-8',
66
- timeout: 120_000,
67
- });
68
- if (result.stdout) {
69
- const rlOut = JSON.parse(result.stdout);
70
- if (rlOut.decision === 'block') {
71
- appendReplayEvent(payload.cwd || process.cwd(), {
72
- host: HOST,
73
- event: 'verify_gate_blocked',
74
- source: 'ralph-loop',
75
- reason: rlOut.reason || '',
76
- });
77
- output(rlOut);
78
- return true;
79
- }
80
- }
81
- } catch {}
82
- return false;
70
+ return runGateScript({
71
+ payload,
72
+ host: HOST,
73
+ scriptPath: join(__dirname, 'ralph-loop.mjs'),
74
+ args: IS_GEMINI ? ['--gemini'] : HOST === 'codex' ? ['--codex'] : [],
75
+ source: 'ralph-loop',
76
+ blockEvent: 'verify_gate_blocked',
77
+ timeout: 120_000,
78
+ appendReplayEvent,
79
+ output,
80
+ });
83
81
  }
84
82
 
85
83
  function runDeliveryGate(payload) {
86
- try {
87
- const gatePath = join(__dirname, 'delivery-gate.mjs');
88
- if (!existsSync(gatePath)) return false;
89
- const result = spawnSync(process.execPath, [gatePath], {
90
- input: JSON.stringify(payload),
91
- encoding: 'utf-8',
92
- timeout: 30_000,
93
- });
94
- if (result.stdout) {
95
- const gateOut = JSON.parse(result.stdout);
96
- if (gateOut.decision === 'block') {
97
- appendReplayEvent(payload.cwd || process.cwd(), {
98
- host: HOST,
99
- event: 'delivery_gate_blocked',
100
- source: 'delivery-gate',
101
- reason: gateOut.reason || '',
102
- });
103
- output(gateOut);
104
- return true;
105
- }
106
- }
107
- } catch {}
108
- return false;
84
+ return runGateScript({
85
+ payload,
86
+ host: HOST,
87
+ scriptPath: join(__dirname, 'delivery-gate.mjs'),
88
+ source: 'delivery-gate',
89
+ blockEvent: 'delivery_gate_blocked',
90
+ timeout: 30_000,
91
+ appendReplayEvent,
92
+ output,
93
+ });
109
94
  }
110
95
 
111
- function readCompletionText(payload = {}) {
112
- return payload['last-assistant-message']
113
- || payload.last_assistant_message
114
- || payload.lastAssistantMessage
115
- || '';
96
+ function readMainTurnState(cwd) {
97
+ const turnState = readTurnState(cwd);
98
+ return turnState?.role === 'main' ? turnState : null;
116
99
  }
117
100
 
118
- function shouldRunDeliveryGate(cwd, lastMsg) {
119
- const turnState = readTurnState(cwd);
120
- if (turnState?.role === 'main') {
121
- return turnState.kind === 'complete';
122
- }
123
- return claimsTaskComplete(lastMsg);
101
+ function consumeMainTurnState(cwd, turnState) {
102
+ if (turnState?.role === 'main') clearTurnState(cwd);
103
+ }
104
+
105
+ function shouldProcessCloseout(turnState) {
106
+ if (turnState) return turnState.kind === 'complete';
107
+ return false;
124
108
  }
125
109
 
126
110
  function cmdPreCompact() {
@@ -202,6 +186,7 @@ function cmdInject() {
202
186
  pkgRoot: PKG_ROOT,
203
187
  host: HOST,
204
188
  cwd,
189
+ payload,
205
190
  });
206
191
  clearRouteContext();
207
192
  clearTurnState(cwd);
@@ -210,24 +195,26 @@ function cmdInject() {
210
195
 
211
196
  function cmdStop() {
212
197
  const payload = readStdinJson();
213
- const lastMsg = readCompletionText(payload);
214
198
  const cwd = payload.cwd || process.cwd();
199
+ const turnState = readMainTurnState(cwd);
200
+ const shouldProcess = shouldProcessCloseout(turnState);
215
201
  clearRouteContext();
216
- if (runRalphLoop(payload)) {
217
- playSound('warning');
218
- desktopNotify('warning', buildNotifyExtra(payload));
202
+ if (shouldProcess && runRalphLoop(payload)) {
203
+ consumeMainTurnState(cwd, turnState);
204
+ notifyByLevel('warning', buildNotifyExtra(payload));
219
205
  return;
220
206
  }
221
- if (shouldRunDeliveryGate(cwd, lastMsg) && runDeliveryGate(payload)) {
222
- playSound('warning');
223
- desktopNotify('warning', buildNotifyExtra(payload));
207
+ if (shouldProcess && runDeliveryGate(payload)) {
208
+ consumeMainTurnState(cwd, turnState);
209
+ notifyByLevel('warning', buildNotifyExtra(payload));
224
210
  return;
225
211
  }
226
212
 
227
213
  const settings = getSettings();
228
- const level = settings.notify_level ?? 0;
229
- if (level === 2 || level === 3) playSound('complete');
230
- if (level === 1 || level === 3) desktopNotify('complete', buildNotifyExtra(payload));
214
+ if (shouldProcess) {
215
+ notifyByLevel('complete', buildNotifyExtra(payload), settings);
216
+ }
217
+ consumeMainTurnState(cwd, turnState);
231
218
  emptySuppress();
232
219
  }
233
220
 
@@ -248,31 +235,33 @@ function cmdCodexNotify() {
248
235
  if (shouldIgnoreCodexNotifyClient(client)) return;
249
236
 
250
237
  if (type === 'approval-requested') {
251
- playSound('confirm');
252
- desktopNotify('confirm', buildNotifyExtra(data));
238
+ notifyByLevel('confirm', buildNotifyExtra(data));
253
239
  return;
254
240
  }
255
241
  if (type !== 'agent-turn-complete') return;
256
242
 
257
243
  const cwd = data.cwd || process.cwd();
258
- const turnState = readTurnState(cwd);
259
- if (!turnState || turnState.role !== 'main') return;
244
+ const turnState = readMainTurnState(cwd);
245
+ if (!turnState) return;
246
+ if (turnState.kind !== 'complete') {
247
+ consumeMainTurnState(cwd, turnState);
248
+ return;
249
+ }
260
250
 
261
251
  const settings = getSettings();
262
- if (turnState.kind === 'complete' && runRalphLoop({ cwd })) {
263
- playSound('warning');
264
- desktopNotify('warning', buildNotifyExtra(data));
252
+ if (runRalphLoop(data)) {
253
+ consumeMainTurnState(cwd, turnState);
254
+ notifyByLevel('warning', buildNotifyExtra(data), settings);
265
255
  return;
266
256
  }
267
- if (turnState.kind === 'complete' && runDeliveryGate({ cwd })) {
268
- playSound('warning');
269
- desktopNotify('warning', buildNotifyExtra(data));
257
+ if (runDeliveryGate(data)) {
258
+ consumeMainTurnState(cwd, turnState);
259
+ notifyByLevel('warning', buildNotifyExtra(data), settings);
270
260
  return;
271
261
  }
272
262
 
273
- const level = settings.notify_level ?? 0;
274
- if (level === 2 || level === 3) playSound('complete');
275
- if (level === 1 || level === 3) desktopNotify('complete', buildNotifyExtra(data));
263
+ notifyByLevel('complete', buildNotifyExtra(data), settings);
264
+ consumeMainTurnState(cwd, turnState);
276
265
  }
277
266
 
278
267
  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`。')
311
+ if (summary.stateSessionMode === 'default') {
312
+ explanations.push('说明:当前宿主未提供稳定会话标识,因此使用分支默认位置。')
313
+ }
314
+ if (summary.usesSharedStore) {
315
+ explanations.push('说明:状态文件与 `.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 }