throughline 0.10.2 → 0.10.5
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.
- package/CHANGELOG.md +88 -27
- package/README.ja.md +83 -49
- package/README.md +106 -78
- package/bin/throughline.mjs +32 -13
- package/docs/00_overview.md +56 -42
- package/docs/01_l1_l2_l3_redesign.md +1 -1
- package/docs/02_clear_auto_handoff_plan.md +39 -333
- package/docs/04_public_release_plan.md +73 -190
- package/docs/05_codex_first_roadmap.md +4 -4
- package/docs/06_codex_trim_rollback_fix_plan.md +1 -1
- package/docs/08_codex_dual_support.md +1 -1
- package/docs/09_rollback_context_trim_insight.md +1 -1
- package/docs/12_desktop_clear_handoff_plan.md +6 -213
- package/docs/15_windows_ci_release_latency_plan.md +6 -87
- package/docs/16_readonly_handoff_context_plan.md +7 -38
- package/docs/adr/0005-observer-read-pagination.md +1 -1
- package/docs/adr/0014-two-phase-handoff-ghost-baton.md +1 -1
- package/docs/adr/0019-product-owned-database-migration-acceptance.md +1 -1
- package/docs/adr/0021-grok-host-capture.md +1 -1
- package/docs/adr/0022-cursor-host-capture.md +39 -0
- package/docs/archive/02_clear_auto_handoff_plan.md +350 -0
- package/docs/{03_inheritance_on_clear_only.md → archive/03_inheritance_on_clear_only.md} +22 -22
- package/docs/{07_codex_trim_implementation_plan.md → archive/07_codex_trim_implementation_plan.md} +8 -8
- package/docs/{10_transcript_injection_plan.md → archive/10_transcript_injection_plan.md} +12 -12
- package/docs/archive/12_desktop_clear_handoff_plan.md +218 -0
- package/docs/{14_observer_completed_turn_feed_plan.md → archive/14_observer_completed_turn_feed_plan.md} +7 -7
- package/docs/archive/15_windows_ci_release_latency_plan.md +89 -0
- package/docs/archive/16_readonly_handoff_context_plan.md +40 -0
- package/docs/archive/README.md +28 -15
- package/docs/archive/plan_grok-successor-launch.md +99 -0
- package/docs/archive/room-log_throughline_20260830-155052.md +285 -0
- package/docs/plan_grok-successor-launch.md +6 -97
- package/package.json +19 -11
- package/rag/INDEX.md +2 -2
- package/src/baton.mjs +11 -9
- package/src/cli/handoff-context.test.mjs +36 -0
- package/src/cli/help.test.mjs +5 -0
- package/src/cli/install.mjs +91 -0
- package/src/cli/install.test.mjs +57 -0
- package/src/cli/runtime-errors.mjs +9 -3
- package/src/cli/runtime-errors.test.mjs +13 -13
- package/src/cli/self-update.mjs +402 -0
- package/src/cli/self-update.test.mjs +525 -0
- package/src/db.mjs +1 -1
- package/src/docs-contract.test.mjs +153 -0
- package/src/hosts/claude.mjs +1 -0
- package/src/hosts/codex.mjs +1 -0
- package/src/hosts/cursor.mjs +128 -0
- package/src/hosts/cursor.test.mjs +104 -0
- package/src/hosts/grok.mjs +1 -0
- package/src/hosts/identity.mjs +19 -2
- package/src/hosts/identity.test.mjs +27 -4
- package/src/hosts/index.mjs +11 -2
- package/src/product-ci-contract.test.mjs +14 -0
- package/src/prompt-submit.mjs +8 -10
- package/src/resume-context.mjs +4 -4
- package/src/runtime-error-hook.test.mjs +4 -6
- package/src/runtime-error-store.mjs +40 -18
- package/src/runtime-error-store.test.mjs +53 -26
- package/src/session-merger.mjs +16 -7
- package/src/session-merger.test.mjs +27 -0
- package/src/session-start.mjs +24 -1
- package/src/spike-transcript-writer.mjs +1 -1
- package/src/transcript-reader-cursor.test.mjs +43 -0
- package/src/transcript-reader.mjs +14 -7
- /package/docs/{11_codex_monitor_implementation_plan.md → archive/11_codex_monitor_implementation_plan.md} +0 -0
- /package/docs/{13_native_factory_diagnostics_plan.md → archive/13_native_factory_diagnostics_plan.md} +0 -0
- /package/docs/{BUGHUB_RUNTIME_ERROR_STORE_PLAN.md → archive/BUGHUB_RUNTIME_ERROR_STORE_PLAN.md} +0 -0
package/src/hosts/claude.mjs
CHANGED
package/src/hosts/codex.mjs
CHANGED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hosts/cursor.mjs — Cursor host 境界 (envelope 正規化 + hook adapter)
|
|
3
|
+
*
|
|
4
|
+
* Cursor は Claude PascalCase でも Grok camelCase wire でもない。
|
|
5
|
+
* hook_event_name は sessionStart / beforeSubmitPrompt / stop などの
|
|
6
|
+
* Cursor envelope。session 識別は conversation_id(sessionStart では
|
|
7
|
+
* session_id と同じ)。L2 は ~/.cursor/projects/<slug>/agent-transcripts/<id>/<id>.jsonl。
|
|
8
|
+
*
|
|
9
|
+
* beforeSubmitPrompt の stdout は continue だけ(additional_context なし)。
|
|
10
|
+
* 引き継ぎ注入は sessionStart の additional_context が公式口。
|
|
11
|
+
* /tl 後継の自動起動はしない(次の新規 Cursor 会話の sessionStart が飲む)。
|
|
12
|
+
*/
|
|
13
|
+
import { homedir } from 'node:os';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
CURSOR_HOST,
|
|
18
|
+
CURSOR_SESSION_PREFIX,
|
|
19
|
+
cursorBareSessionId,
|
|
20
|
+
isCursorSessionId,
|
|
21
|
+
} from './identity.mjs';
|
|
22
|
+
|
|
23
|
+
const CURSOR_HOOK_EVENTS = new Set([
|
|
24
|
+
'sessionStart',
|
|
25
|
+
'sessionEnd',
|
|
26
|
+
'beforeSubmitPrompt',
|
|
27
|
+
'stop',
|
|
28
|
+
'preToolUse',
|
|
29
|
+
'postToolUse',
|
|
30
|
+
'postToolUseFailure',
|
|
31
|
+
'subagentStart',
|
|
32
|
+
'subagentStop',
|
|
33
|
+
'beforeShellExecution',
|
|
34
|
+
'afterShellExecution',
|
|
35
|
+
'beforeMCPExecution',
|
|
36
|
+
'afterMCPExecution',
|
|
37
|
+
'beforeReadFile',
|
|
38
|
+
'afterFileEdit',
|
|
39
|
+
'preCompact',
|
|
40
|
+
'afterAgentResponse',
|
|
41
|
+
'afterAgentThought',
|
|
42
|
+
'beforeTabFileRead',
|
|
43
|
+
'afterTabFileEdit',
|
|
44
|
+
'workspaceOpen',
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
export function isCursorEnvelope(payload) {
|
|
48
|
+
if (payload === null || typeof payload !== 'object') return false;
|
|
49
|
+
if (typeof payload.cursor_version === 'string' && payload.cursor_version.length > 0) {
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
return typeof payload.hook_event_name === 'string'
|
|
53
|
+
&& CURSOR_HOOK_EVENTS.has(payload.hook_event_name);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function encodeCursorProjectDir(projectPath) {
|
|
57
|
+
if (typeof projectPath !== 'string' || projectPath.length === 0) return null;
|
|
58
|
+
const posix = projectPath.replace(/\\/g, '/').replace(/^\/+/, '');
|
|
59
|
+
const encoded = posix.replace(/:/g, '').replace(/\//g, '-');
|
|
60
|
+
return encoded.length > 0 ? encoded : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function deriveCursorTranscriptPath(projectPath, sessionId, { home = homedir() } = {}) {
|
|
64
|
+
const bare = cursorBareSessionId(sessionId);
|
|
65
|
+
const slug = encodeCursorProjectDir(projectPath);
|
|
66
|
+
if (!bare || !slug) return null;
|
|
67
|
+
return join(home, '.cursor', 'projects', slug, 'agent-transcripts', bare, `${bare}.jsonl`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function cursorCwd(payload) {
|
|
71
|
+
if (typeof payload.cwd === 'string' && payload.cwd.length > 0) return payload.cwd;
|
|
72
|
+
const roots = payload.workspace_roots;
|
|
73
|
+
if (Array.isArray(roots) && typeof roots[0] === 'string' && roots[0].length > 0) {
|
|
74
|
+
return roots[0];
|
|
75
|
+
}
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function cursorRawSessionId(payload) {
|
|
80
|
+
if (typeof payload.conversation_id === 'string' && payload.conversation_id.length > 0) {
|
|
81
|
+
return payload.conversation_id;
|
|
82
|
+
}
|
|
83
|
+
if (typeof payload.session_id === 'string' && payload.session_id.length > 0) {
|
|
84
|
+
return payload.session_id;
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function normalizeCursorHookPayload(payload, { home = homedir() } = {}) {
|
|
90
|
+
if (!isCursorEnvelope(payload)) return payload;
|
|
91
|
+
const rawId = cursorRawSessionId(payload);
|
|
92
|
+
const cwd = cursorCwd(payload);
|
|
93
|
+
const prefixed = rawId ? `${CURSOR_SESSION_PREFIX}${cursorBareSessionId(rawId)}` : undefined;
|
|
94
|
+
const givenTranscript = typeof payload.transcript_path === 'string' && payload.transcript_path.length > 0
|
|
95
|
+
? payload.transcript_path
|
|
96
|
+
: null;
|
|
97
|
+
const transcriptPath = givenTranscript ?? deriveCursorTranscriptPath(cwd, prefixed, { home });
|
|
98
|
+
return {
|
|
99
|
+
...payload,
|
|
100
|
+
session_id: prefixed,
|
|
101
|
+
cwd,
|
|
102
|
+
source: typeof payload.source === 'string' && payload.source.length > 0
|
|
103
|
+
? payload.source
|
|
104
|
+
: 'startup',
|
|
105
|
+
prompt: payload.prompt,
|
|
106
|
+
hook_event_name: payload.hook_event_name,
|
|
107
|
+
transcript_path: transcriptPath,
|
|
108
|
+
last_assistant_message: payload.last_assistant_message ?? payload.text,
|
|
109
|
+
is_background_agent: payload.is_background_agent === true,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export const cursorHostAdapter = Object.freeze({
|
|
114
|
+
host: CURSOR_HOST,
|
|
115
|
+
matchesSessionId: isCursorSessionId,
|
|
116
|
+
waitsForStopTranscriptFlush: false,
|
|
117
|
+
consumesHandoffAtSessionStart: true,
|
|
118
|
+
deliverHandoffInjection({ text, stdout = process.stdout }) {
|
|
119
|
+
stdout.write(`${JSON.stringify({ additional_context: text })}\n`);
|
|
120
|
+
return { delivered: true };
|
|
121
|
+
},
|
|
122
|
+
resolveCommandPrompt({ prompt }) {
|
|
123
|
+
return prompt;
|
|
124
|
+
},
|
|
125
|
+
afterBatonWrite() {
|
|
126
|
+
return { launched: false };
|
|
127
|
+
},
|
|
128
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
deriveCursorTranscriptPath,
|
|
7
|
+
encodeCursorProjectDir,
|
|
8
|
+
isCursorEnvelope,
|
|
9
|
+
normalizeHookPayload,
|
|
10
|
+
} from './index.mjs';
|
|
11
|
+
|
|
12
|
+
test('isCursorEnvelope detects Cursor events and cursor_version', () => {
|
|
13
|
+
assert.equal(
|
|
14
|
+
isCursorEnvelope({
|
|
15
|
+
hook_event_name: 'sessionStart',
|
|
16
|
+
session_id: '7face712-ef58-40d7-b71e-b71091dfee5c',
|
|
17
|
+
workspace_roots: ['/Users/kite/Developer/dotagents'],
|
|
18
|
+
}),
|
|
19
|
+
true,
|
|
20
|
+
);
|
|
21
|
+
assert.equal(
|
|
22
|
+
isCursorEnvelope({
|
|
23
|
+
cursor_version: '1.7.2',
|
|
24
|
+
conversation_id: '7face712-ef58-40d7-b71e-b71091dfee5c',
|
|
25
|
+
}),
|
|
26
|
+
true,
|
|
27
|
+
);
|
|
28
|
+
assert.equal(
|
|
29
|
+
isCursorEnvelope({
|
|
30
|
+
session_id: 'claude-session',
|
|
31
|
+
hook_event_name: 'SessionStart',
|
|
32
|
+
}),
|
|
33
|
+
false,
|
|
34
|
+
);
|
|
35
|
+
assert.equal(
|
|
36
|
+
isCursorEnvelope({
|
|
37
|
+
sessionId: '01a00aa2-dead-beef',
|
|
38
|
+
hookEventName: 'session_start',
|
|
39
|
+
}),
|
|
40
|
+
false,
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('normalizeHookPayload prefixes cursor: and derives agent-transcripts path', () => {
|
|
45
|
+
const home = '/tmp/tl-home';
|
|
46
|
+
const cwd = '/Users/kite/Developer/dotagents';
|
|
47
|
+
const payload = normalizeHookPayload(
|
|
48
|
+
{
|
|
49
|
+
conversation_id: '7face712-ef58-40d7-b71e-b71091dfee5c',
|
|
50
|
+
hook_event_name: 'beforeSubmitPrompt',
|
|
51
|
+
workspace_roots: [cwd],
|
|
52
|
+
prompt: '/tl',
|
|
53
|
+
cursor_version: '1.7.2',
|
|
54
|
+
},
|
|
55
|
+
{ home },
|
|
56
|
+
);
|
|
57
|
+
assert.equal(payload.session_id, 'cursor:7face712-ef58-40d7-b71e-b71091dfee5c');
|
|
58
|
+
assert.equal(payload.cwd, cwd);
|
|
59
|
+
assert.equal(payload.prompt, '/tl');
|
|
60
|
+
assert.equal(
|
|
61
|
+
payload.transcript_path,
|
|
62
|
+
join(
|
|
63
|
+
home,
|
|
64
|
+
'.cursor',
|
|
65
|
+
'projects',
|
|
66
|
+
'Users-kite-Developer-dotagents',
|
|
67
|
+
'agent-transcripts',
|
|
68
|
+
'7face712-ef58-40d7-b71e-b71091dfee5c',
|
|
69
|
+
'7face712-ef58-40d7-b71e-b71091dfee5c.jsonl',
|
|
70
|
+
),
|
|
71
|
+
);
|
|
72
|
+
assert.equal(
|
|
73
|
+
deriveCursorTranscriptPath(cwd, 'cursor:7face712-ef58-40d7-b71e-b71091dfee5c', { home }),
|
|
74
|
+
payload.transcript_path,
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('normalizeHookPayload strips bc- prefix and keeps payload transcript_path', () => {
|
|
79
|
+
const home = '/tmp/tl-home';
|
|
80
|
+
const given = '/tmp/given.jsonl';
|
|
81
|
+
const payload = normalizeHookPayload(
|
|
82
|
+
{
|
|
83
|
+
conversation_id: 'bc-7face712-ef58-40d7-b71e-b71091dfee5c',
|
|
84
|
+
hook_event_name: 'stop',
|
|
85
|
+
cwd: '/Users/kite/Developer/dotagents',
|
|
86
|
+
transcript_path: given,
|
|
87
|
+
status: 'completed',
|
|
88
|
+
},
|
|
89
|
+
{ home },
|
|
90
|
+
);
|
|
91
|
+
assert.equal(payload.session_id, 'cursor:7face712-ef58-40d7-b71e-b71091dfee5c');
|
|
92
|
+
assert.equal(payload.transcript_path, given);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('encodeCursorProjectDir matches ~/.cursor/projects slug', () => {
|
|
96
|
+
assert.equal(
|
|
97
|
+
encodeCursorProjectDir('/Users/kite/Developer/dotagents'),
|
|
98
|
+
'Users-kite-Developer-dotagents',
|
|
99
|
+
);
|
|
100
|
+
assert.equal(
|
|
101
|
+
encodeCursorProjectDir(String.raw`C:\Users\kite_\Developer\dotagents`),
|
|
102
|
+
'C-Users-kite_-Developer-dotagents',
|
|
103
|
+
);
|
|
104
|
+
});
|
package/src/hosts/grok.mjs
CHANGED
|
@@ -88,6 +88,7 @@ export const grokHostAdapter = Object.freeze({
|
|
|
88
88
|
return lastUserPromptText(payload.transcript_path);
|
|
89
89
|
},
|
|
90
90
|
// Grok `/tl` 成功後だけ、源セッションの project_path で後継の対話 grok を立てる。
|
|
91
|
+
consumesHandoffAtSessionStart: false,
|
|
91
92
|
afterBatonWrite({ trigger, sessionId, cwd, continueRun = runGrokContinue }) {
|
|
92
93
|
if (trigger !== 'tl') return { launched: false };
|
|
93
94
|
const code = continueRun(['--session', sessionId], { cwd });
|
package/src/hosts/identity.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* hosts/identity.mjs — harness (hook host) 識別の唯一の正本
|
|
3
3
|
*
|
|
4
|
-
* Throughline は Claude / Codex / Grok の
|
|
4
|
+
* Throughline は Claude / Codex / Grok / Cursor の hook host を同じ SQLite に保存する。
|
|
5
5
|
* host の見分け方は session_id prefix だけであり、その prefix 定義と判定関数を
|
|
6
6
|
* このファイルに一元化する。共有コード (hook 入口・monitor・state-file・
|
|
7
7
|
* predecessor 検索) は文字列リテラルを直接持たず、必ずここを参照する。
|
|
@@ -13,9 +13,11 @@
|
|
|
13
13
|
export const CLAUDE_HOST = 'claude';
|
|
14
14
|
export const CODEX_HOST = 'codex';
|
|
15
15
|
export const GROK_HOST = 'grok';
|
|
16
|
+
export const CURSOR_HOST = 'cursor';
|
|
16
17
|
|
|
17
18
|
export const CODEX_SESSION_PREFIX = 'codex:';
|
|
18
19
|
export const GROK_SESSION_PREFIX = 'grok:';
|
|
20
|
+
export const CURSOR_SESSION_PREFIX = 'cursor:';
|
|
19
21
|
|
|
20
22
|
/**
|
|
21
23
|
* Claude session は prefix を持たない。auto handoff の前任検索 (Claude 専用) は
|
|
@@ -24,6 +26,7 @@ export const GROK_SESSION_PREFIX = 'grok:';
|
|
|
24
26
|
export const NON_CLAUDE_SESSION_PREFIXES = Object.freeze([
|
|
25
27
|
CODEX_SESSION_PREFIX,
|
|
26
28
|
GROK_SESSION_PREFIX,
|
|
29
|
+
CURSOR_SESSION_PREFIX,
|
|
27
30
|
]);
|
|
28
31
|
|
|
29
32
|
/**
|
|
@@ -40,14 +43,19 @@ export function isGrokSessionId(sessionId) {
|
|
|
40
43
|
return typeof sessionId === 'string' && sessionId.startsWith(GROK_SESSION_PREFIX);
|
|
41
44
|
}
|
|
42
45
|
|
|
46
|
+
export function isCursorSessionId(sessionId) {
|
|
47
|
+
return typeof sessionId === 'string' && sessionId.startsWith(CURSOR_SESSION_PREFIX);
|
|
48
|
+
}
|
|
49
|
+
|
|
43
50
|
/**
|
|
44
51
|
* session_id から hook host を返す。prefix なしは Claude。
|
|
45
52
|
* @param {string} sessionId
|
|
46
|
-
* @returns {'claude'|'codex'|'grok'}
|
|
53
|
+
* @returns {'claude'|'codex'|'grok'|'cursor'}
|
|
47
54
|
*/
|
|
48
55
|
export function hostOfSessionId(sessionId) {
|
|
49
56
|
if (isCodexSessionId(sessionId)) return CODEX_HOST;
|
|
50
57
|
if (isGrokSessionId(sessionId)) return GROK_HOST;
|
|
58
|
+
if (isCursorSessionId(sessionId)) return CURSOR_HOST;
|
|
51
59
|
return CLAUDE_HOST;
|
|
52
60
|
}
|
|
53
61
|
|
|
@@ -69,3 +77,12 @@ export function grokBareSessionId(sessionId) {
|
|
|
69
77
|
? sessionId.slice(GROK_SESSION_PREFIX.length)
|
|
70
78
|
: sessionId;
|
|
71
79
|
}
|
|
80
|
+
|
|
81
|
+
export function cursorBareSessionId(sessionId) {
|
|
82
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0) return null;
|
|
83
|
+
let bare = sessionId.startsWith(CURSOR_SESSION_PREFIX)
|
|
84
|
+
? sessionId.slice(CURSOR_SESSION_PREFIX.length)
|
|
85
|
+
: sessionId;
|
|
86
|
+
if (bare.startsWith('bc-')) bare = bare.slice(3);
|
|
87
|
+
return bare.length > 0 ? bare : null;
|
|
88
|
+
}
|
|
@@ -3,13 +3,16 @@ import assert from 'node:assert/strict';
|
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
5
|
CODEX_SESSION_PREFIX,
|
|
6
|
+
CURSOR_SESSION_PREFIX,
|
|
6
7
|
GROK_SESSION_PREFIX,
|
|
7
8
|
NON_CLAUDE_SESSION_PREFIXES,
|
|
8
9
|
buildCodexThroughlineSessionId,
|
|
9
10
|
codexSessionIdToThreadId,
|
|
11
|
+
cursorBareSessionId,
|
|
10
12
|
grokBareSessionId,
|
|
11
13
|
hostOfSessionId,
|
|
12
14
|
isCodexSessionId,
|
|
15
|
+
isCursorSessionId,
|
|
13
16
|
isGrokSessionId,
|
|
14
17
|
} from './identity.mjs';
|
|
15
18
|
import { hostAdapterForSessionId } from './index.mjs';
|
|
@@ -17,12 +20,14 @@ import { hostAdapterForSessionId } from './index.mjs';
|
|
|
17
20
|
test('session prefixes are the canonical wire values', () => {
|
|
18
21
|
assert.equal(CODEX_SESSION_PREFIX, 'codex:');
|
|
19
22
|
assert.equal(GROK_SESSION_PREFIX, 'grok:');
|
|
20
|
-
assert.
|
|
23
|
+
assert.equal(CURSOR_SESSION_PREFIX, 'cursor:');
|
|
24
|
+
assert.deepEqual([...NON_CLAUDE_SESSION_PREFIXES], ['codex:', 'grok:', 'cursor:']);
|
|
21
25
|
});
|
|
22
26
|
|
|
23
27
|
test('hostOfSessionId maps prefixes to hosts and defaults to claude', () => {
|
|
24
28
|
assert.equal(hostOfSessionId('codex:0199aa'), 'codex');
|
|
25
29
|
assert.equal(hostOfSessionId('grok:0199aa'), 'grok');
|
|
30
|
+
assert.equal(hostOfSessionId('cursor:0199aa'), 'cursor');
|
|
26
31
|
assert.equal(hostOfSessionId('0199aa-claude'), 'claude');
|
|
27
32
|
assert.equal(hostOfSessionId(undefined), 'claude');
|
|
28
33
|
});
|
|
@@ -41,25 +46,43 @@ test('grokBareSessionId strips only the grok prefix', () => {
|
|
|
41
46
|
assert.equal(grokBareSessionId(''), null);
|
|
42
47
|
});
|
|
43
48
|
|
|
44
|
-
test('
|
|
49
|
+
test('cursorBareSessionId strips cursor: and optional bc- prefix', () => {
|
|
50
|
+
assert.equal(cursorBareSessionId('cursor:abc'), 'abc');
|
|
51
|
+
assert.equal(cursorBareSessionId('bc-abc'), 'abc');
|
|
52
|
+
assert.equal(cursorBareSessionId('cursor:bc-abc'), 'abc');
|
|
53
|
+
assert.equal(cursorBareSessionId('abc'), 'abc');
|
|
54
|
+
assert.equal(cursorBareSessionId(''), null);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('isCodexSessionId / isGrokSessionId / isCursorSessionId reject non-strings', () => {
|
|
45
58
|
assert.equal(isCodexSessionId(null), false);
|
|
46
59
|
assert.equal(isGrokSessionId(undefined), false);
|
|
60
|
+
assert.equal(isCursorSessionId(undefined), false);
|
|
47
61
|
});
|
|
48
62
|
|
|
49
63
|
test('every host adapter satisfies the shared hook contract', () => {
|
|
50
|
-
for (const sessionId of ['claude-session', 'codex:t1', 'grok:t1']) {
|
|
64
|
+
for (const sessionId of ['claude-session', 'codex:t1', 'grok:t1', 'cursor:t1']) {
|
|
51
65
|
const adapter = hostAdapterForSessionId(sessionId);
|
|
52
66
|
assert.equal(typeof adapter.host, 'string');
|
|
53
67
|
assert.equal(adapter.matchesSessionId(sessionId), true);
|
|
54
68
|
assert.equal(typeof adapter.waitsForStopTranscriptFlush, 'boolean');
|
|
69
|
+
assert.equal(typeof adapter.consumesHandoffAtSessionStart, 'boolean');
|
|
55
70
|
assert.equal(typeof adapter.deliverHandoffInjection, 'function');
|
|
56
71
|
assert.equal(typeof adapter.resolveCommandPrompt, 'function');
|
|
57
72
|
assert.equal(typeof adapter.afterBatonWrite, 'function');
|
|
58
73
|
}
|
|
59
74
|
});
|
|
60
75
|
|
|
61
|
-
test('flush barrier applies to claude and codex sessions, not grok', () => {
|
|
76
|
+
test('flush barrier applies to claude and codex sessions, not grok or cursor', () => {
|
|
62
77
|
assert.equal(hostAdapterForSessionId('claude-session').waitsForStopTranscriptFlush, true);
|
|
63
78
|
assert.equal(hostAdapterForSessionId('codex:t1').waitsForStopTranscriptFlush, true);
|
|
64
79
|
assert.equal(hostAdapterForSessionId('grok:t1').waitsForStopTranscriptFlush, false);
|
|
80
|
+
assert.equal(hostAdapterForSessionId('cursor:t1').waitsForStopTranscriptFlush, false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('only Cursor consumes handoff at sessionStart', () => {
|
|
84
|
+
assert.equal(hostAdapterForSessionId('claude-session').consumesHandoffAtSessionStart, false);
|
|
85
|
+
assert.equal(hostAdapterForSessionId('codex:t1').consumesHandoffAtSessionStart, false);
|
|
86
|
+
assert.equal(hostAdapterForSessionId('grok:t1').consumesHandoffAtSessionStart, false);
|
|
87
|
+
assert.equal(hostAdapterForSessionId('cursor:t1').consumesHandoffAtSessionStart, true);
|
|
65
88
|
});
|
package/src/hosts/index.mjs
CHANGED
|
@@ -5,26 +5,35 @@
|
|
|
5
5
|
* `normalizeHookPayload` と `hostAdapterForSessionId` だけを使い、
|
|
6
6
|
* ベンダー分岐を直接書かない。
|
|
7
7
|
*/
|
|
8
|
-
import { hostOfSessionId, CLAUDE_HOST, CODEX_HOST, GROK_HOST } from './identity.mjs';
|
|
8
|
+
import { hostOfSessionId, CLAUDE_HOST, CODEX_HOST, GROK_HOST, CURSOR_HOST } from './identity.mjs';
|
|
9
9
|
import { claudeHostAdapter } from './claude.mjs';
|
|
10
10
|
import { codexHostAdapter } from './codex.mjs';
|
|
11
11
|
import { grokHostAdapter, isGrokEnvelope, normalizeGrokHookPayload } from './grok.mjs';
|
|
12
|
+
import { cursorHostAdapter, isCursorEnvelope, normalizeCursorHookPayload } from './cursor.mjs';
|
|
12
13
|
|
|
13
14
|
export * from './identity.mjs';
|
|
14
15
|
export { isGrokEnvelope, deriveGrokChatHistoryPath, normalizeGrokHookPayload } from './grok.mjs';
|
|
16
|
+
export {
|
|
17
|
+
isCursorEnvelope,
|
|
18
|
+
encodeCursorProjectDir,
|
|
19
|
+
deriveCursorTranscriptPath,
|
|
20
|
+
normalizeCursorHookPayload,
|
|
21
|
+
} from './cursor.mjs';
|
|
15
22
|
|
|
16
23
|
const ADAPTERS = Object.freeze({
|
|
17
24
|
[CLAUDE_HOST]: claudeHostAdapter,
|
|
18
25
|
[CODEX_HOST]: codexHostAdapter,
|
|
19
26
|
[GROK_HOST]: grokHostAdapter,
|
|
27
|
+
[CURSOR_HOST]: cursorHostAdapter,
|
|
20
28
|
});
|
|
21
29
|
|
|
22
30
|
/**
|
|
23
31
|
* hook stdin payload を Claude snake_case 契約へ正規化する。
|
|
24
|
-
*
|
|
32
|
+
* Grok は camelCase wire、Cursor は hook_event_name が sessionStart 等。
|
|
25
33
|
*/
|
|
26
34
|
export function normalizeHookPayload(payload, options = {}) {
|
|
27
35
|
if (isGrokEnvelope(payload)) return normalizeGrokHookPayload(payload, options);
|
|
36
|
+
if (isCursorEnvelope(payload)) return normalizeCursorHookPayload(payload, options);
|
|
28
37
|
return payload;
|
|
29
38
|
}
|
|
30
39
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { test } from 'node:test';
|
|
4
|
+
|
|
5
|
+
test('windows-native product CI uses PowerShell 7 only', () => {
|
|
6
|
+
const source = readFileSync(new URL('../.github/workflows/product-full-ci.yml', import.meta.url), 'utf8');
|
|
7
|
+
const windowsBlocks = source
|
|
8
|
+
.split(/\n(?= {6}- name:)/)
|
|
9
|
+
.filter((block) => block.includes("matrix.environment == 'windows-native'"));
|
|
10
|
+
assert.equal(windowsBlocks.length, 3);
|
|
11
|
+
for (const block of windowsBlocks) assert.match(block, /\n shell: pwsh\n/);
|
|
12
|
+
assert.match(windowsBlocks[0], /\$env:GITHUB_ENV/);
|
|
13
|
+
assert.doesNotMatch(source, /Git\\bin\\bash|Git\/bin\/bash|shell:\s*(?:powershell|cmd)\b/i);
|
|
14
|
+
});
|
package/src/prompt-submit.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* UserPromptSubmit hook — 二相ハンドオフ第二相 + /tl
|
|
3
|
+
* UserPromptSubmit hook — 二相ハンドオフ第二相 + /tl バトン書き込み + Phase 0-5 spike
|
|
4
4
|
*
|
|
5
5
|
* stdin: { session_id, cwd, prompt, transcript_path, hook_event_name, ... }
|
|
6
6
|
*
|
|
@@ -13,23 +13,21 @@
|
|
|
13
13
|
* 注入がこの hook に移ったため、SessionStart 側の注入は廃止済み
|
|
14
14
|
* (旧「二重注入回避」制約はこの構成では発生しない)。
|
|
15
15
|
* - prompt が /tl (単独 or /tl ... 形式) で始まっていればバトンを書き込んで終了
|
|
16
|
-
* -
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* 確定的に「/clear が打たれたセッション」を新セッションに引き継げる。複数
|
|
20
|
-
* VSCode ウィンドウ等で「最新更新セッション = clear されたセッション」が
|
|
21
|
-
* 成立しない multi-window シナリオで誤った前任を選ばないための確定的指名)
|
|
16
|
+
* - `/clear` 文字列を受け取った場合の互換バトン分岐は残す。ただし、実測した
|
|
17
|
+
* Claude Code の組み込み `/clear` はこの hook に届かない。VS Code は
|
|
18
|
+
* SessionStart source='clear' の auto path、Desktop は事前 `/tl` を使う。
|
|
22
19
|
* - それ以外は何もせず exit 0(プロンプトはそのまま Claude に渡る)
|
|
23
|
-
* -
|
|
20
|
+
* - pending intent がある最初のプロンプトでは、本 hook が引き継ぎ注入を行う。
|
|
24
21
|
*
|
|
25
22
|
* - **Phase 0-5 spike (SPIKE ONLY)**: marker file `~/.throughline/spike-prompt.flag`
|
|
26
23
|
* が存在し、当該セッションでまだ spike を打っていなければ、JSONL に user/assistant
|
|
27
24
|
* 行を chain-reachable (= 直前の attachment uuid を parent に取る) で append する。
|
|
28
25
|
* SessionStart 経路の spike (chain (a) = orphan) ではモデルに届かなかったため、
|
|
29
26
|
* UserPromptSubmit 経路で chain (b) を成立させて再検証する。
|
|
30
|
-
* docs/10_transcript_injection_plan.md Phase 0-5 参照。
|
|
27
|
+
* docs/archive/10_transcript_injection_plan.md Phase 0-5 参照。
|
|
31
28
|
*
|
|
32
|
-
* 設計背景: docs/03_inheritance_on_clear_only.md バトン方式
|
|
29
|
+
* 設計背景: docs/archive/03_inheritance_on_clear_only.md バトン方式
|
|
30
|
+
* 現行契約: docs/02_clear_auto_handoff_plan.md
|
|
33
31
|
*/
|
|
34
32
|
|
|
35
33
|
import { getDb } from './db.mjs';
|
package/src/resume-context.mjs
CHANGED
|
@@ -2,16 +2,16 @@
|
|
|
2
2
|
* resume-context.mjs — 引継ぎ注入テキストを組み立てる共有モジュール
|
|
3
3
|
*
|
|
4
4
|
* 呼び出し元:
|
|
5
|
-
* -
|
|
5
|
+
* - handoff-executor.mjs(最初のUserPromptSubmitでauto path / baton path共通注入)
|
|
6
6
|
*
|
|
7
7
|
* 設計 (docs/02_clear_auto_handoff_plan.md):
|
|
8
|
-
* -
|
|
8
|
+
* - 予算付き注入: ヘッダ + 現在地アンカー + pull案内を固定部とし、残りへ
|
|
9
|
+
* 直近L2ターン全文を新しい順にターン原子で詰める。L1は直接注入しない。
|
|
9
10
|
* - 「現在地」アンカーは直前の user / assistant turn をヘッダ直下に再掲して
|
|
10
11
|
* 最初の注意を最新ターンに固定する。L2 末尾アンカーは補強として残す。
|
|
11
12
|
* (L2 が長くなると末尾アンカーだけでは前半の古いターンに注意が固着し、
|
|
12
13
|
* 話の流れを取り違える事例があった)
|
|
13
|
-
* - L3
|
|
14
|
-
* `[→ throughline detail HH:MM:SS (kind …)]` ヒントを付ける
|
|
14
|
+
* - L3は本文を注入せず、`throughline detail HH:MM:SS`でpullする
|
|
15
15
|
* - L2 全文があれば最後の assistant turn 自体に「次に何をしようとしていたか」が
|
|
16
16
|
* 含まれるため、memo / thinking / 末尾の再開指示は注入しない
|
|
17
17
|
* - 各行頭に [HH:MM:SS] 時刻プレフィックス(L2 は body の created_at、
|
|
@@ -5,7 +5,7 @@ import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
|
|
5
5
|
import { tmpdir } from 'node:os';
|
|
6
6
|
import { dirname, join } from 'node:path';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
|
-
import {
|
|
8
|
+
import { defaultRuntimeErrorConfigPath, defaultRuntimeErrorStorePath } from './runtime-error-store.mjs';
|
|
9
9
|
import { applyWindowsPrivateAcl } from './os/windows-acl-test-helper.mjs';
|
|
10
10
|
|
|
11
11
|
const BIN = fileURLToPath(new URL('../bin/throughline.mjs', import.meta.url));
|
|
@@ -20,13 +20,11 @@ function createEnabledEnvironment(prefix) {
|
|
|
20
20
|
XDG_CONFIG_HOME: join(root, 'config'),
|
|
21
21
|
XDG_STATE_HOME: join(root, 'state'),
|
|
22
22
|
};
|
|
23
|
-
const configPath =
|
|
23
|
+
const configPath = defaultRuntimeErrorConfigPath(env);
|
|
24
24
|
mkdirSync(dirname(configPath), { recursive: true });
|
|
25
25
|
writeFileSync(configPath, JSON.stringify({
|
|
26
|
-
|
|
27
|
-
host: { id: 'test-host', profile: process.platform === 'win32' ? 'windows-native' : 'mac' },
|
|
26
|
+
schema: 'throughline.runtime_error_config.v1',
|
|
28
27
|
collection: { enabled: true },
|
|
29
|
-
reporting: { enabled: false },
|
|
30
28
|
}));
|
|
31
29
|
applyWindowsPrivateAcl(configPath);
|
|
32
30
|
return { root, env };
|
|
@@ -90,7 +88,7 @@ test('store failure preserves product failure and emits only fixed storage diagn
|
|
|
90
88
|
|
|
91
89
|
test('FIFO config cannot block the original hook failure', { skip: process.platform === 'win32' }, () => {
|
|
92
90
|
const { env } = createEnabledEnvironment('throughline-runtime-hook-fifo-');
|
|
93
|
-
const config =
|
|
91
|
+
const config = defaultRuntimeErrorConfigPath(env);
|
|
94
92
|
execFileSync('rm', ['-f', config]);
|
|
95
93
|
execFileSync('mkfifo', [config]);
|
|
96
94
|
const started = Date.now();
|
|
@@ -22,6 +22,7 @@ const require = createRequire(import.meta.url);
|
|
|
22
22
|
const PACKAGE_VERSION = require('../package.json').version;
|
|
23
23
|
|
|
24
24
|
export const RUNTIME_ERROR_STORE_SCHEMA = 'throughline.runtime_errors.v1';
|
|
25
|
+
export const RUNTIME_ERROR_CONFIG_SCHEMA = 'throughline.runtime_error_config.v1';
|
|
25
26
|
export const RUNTIME_ERROR_STATE_SCHEMA_VERSION = '1.0';
|
|
26
27
|
export const RUNTIME_ERROR_DIAGNOSTIC = '[throughline:runtime-errors] store_unavailable\n';
|
|
27
28
|
const DEFAULT_SNAPSHOT_LIMIT = 256;
|
|
@@ -53,11 +54,11 @@ const DEFINITIONS = Object.freeze({
|
|
|
53
54
|
}),
|
|
54
55
|
});
|
|
55
56
|
|
|
56
|
-
export function
|
|
57
|
+
export function defaultRuntimeErrorConfigPath(env = process.env) {
|
|
57
58
|
if (isWindows(env)) {
|
|
58
|
-
return join(windowsLocalAppData(env), '
|
|
59
|
+
return join(windowsLocalAppData(env), 'throughline', 'runtime-errors.config.json');
|
|
59
60
|
}
|
|
60
|
-
return join(xdgConfigHome(env), '
|
|
61
|
+
return join(xdgConfigHome(env), 'throughline', 'runtime-errors.config.json');
|
|
61
62
|
}
|
|
62
63
|
|
|
63
64
|
export function defaultRuntimeErrorStorePath(env = process.env) {
|
|
@@ -69,16 +70,46 @@ export function defaultRuntimeErrorStorePath(env = process.env) {
|
|
|
69
70
|
|
|
70
71
|
export function isRuntimeErrorCollectionEnabled({ env = process.env, configPath } = {}) {
|
|
71
72
|
try {
|
|
72
|
-
const path = configPath ||
|
|
73
|
+
const path = configPath || defaultRuntimeErrorConfigPath(env);
|
|
73
74
|
const info = lstatSync(path);
|
|
74
75
|
if (!info.isFile() || info.isSymbolicLink()) return false;
|
|
75
76
|
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
76
|
-
return
|
|
77
|
+
return isCanonicalRuntimeErrorConfig(parsed) && parsed.collection.enabled === true;
|
|
77
78
|
} catch {
|
|
78
79
|
return false;
|
|
79
80
|
}
|
|
80
81
|
}
|
|
81
82
|
|
|
83
|
+
export function setRuntimeErrorCollectionEnabled(enabled, options = {}) {
|
|
84
|
+
assertExactOptions(options, ['env', 'configPath']);
|
|
85
|
+
if (typeof enabled !== 'boolean') throw new TypeError('enabled は boolean が必要です');
|
|
86
|
+
const env = options.env ?? process.env;
|
|
87
|
+
const path = options.configPath || defaultRuntimeErrorConfigPath(env);
|
|
88
|
+
const directory = dirname(path);
|
|
89
|
+
ensurePrivateStoreDirectory(directory, env);
|
|
90
|
+
const temporary = join(directory, `.runtime-errors-config.${process.pid}.${randomBytes(6).toString('hex')}.tmp`);
|
|
91
|
+
const config = {
|
|
92
|
+
schema: RUNTIME_ERROR_CONFIG_SCHEMA,
|
|
93
|
+
collection: { enabled },
|
|
94
|
+
};
|
|
95
|
+
try {
|
|
96
|
+
writeFileSync(temporary, `${JSON.stringify(config)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
|
97
|
+
if (isWindows(env)) {
|
|
98
|
+
applyAndVerifyWindowsAcl(temporary, false);
|
|
99
|
+
assertPrivateStoreFileShape(lstatSync(temporary));
|
|
100
|
+
} else {
|
|
101
|
+
chmodSync(temporary, 0o600);
|
|
102
|
+
assertPrivateStoreFile(lstatSync(temporary), env, temporary);
|
|
103
|
+
}
|
|
104
|
+
renameSync(temporary, path);
|
|
105
|
+
if (isWindows(env)) assertPrivateStoreFileShape(lstatSync(path));
|
|
106
|
+
else assertPrivateStoreFile(lstatSync(path), env, path);
|
|
107
|
+
return config;
|
|
108
|
+
} finally {
|
|
109
|
+
rmSync(temporary, { force: true });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
82
113
|
export function observeRuntimeError(input, options = {}) {
|
|
83
114
|
assertExactInput(input, ['code', 'now']);
|
|
84
115
|
assertExactOptions(options, ['env', 'configPath', 'storePath', 'version', 'platform', 'arch']);
|
|
@@ -410,20 +441,11 @@ function assertPrivateStoreFileShape(info) {
|
|
|
410
441
|
}
|
|
411
442
|
}
|
|
412
443
|
|
|
413
|
-
function
|
|
414
|
-
if (!isPlainObject(value) || !exactKeys(value, ['
|
|
415
|
-
|
|
416
|
-
typeof value.host.id !== 'string' || !/^[a-z0-9][a-z0-9._-]{0,63}$/.test(value.host.id) ||
|
|
417
|
-
!['server', 'mac', 'wsl', 'windows-native'].includes(value.host.profile)) return false;
|
|
444
|
+
function isCanonicalRuntimeErrorConfig(value) {
|
|
445
|
+
if (!isPlainObject(value) || !exactKeys(value, ['schema', 'collection']) ||
|
|
446
|
+
value.schema !== RUNTIME_ERROR_CONFIG_SCHEMA) return false;
|
|
418
447
|
if (!isPlainObject(value.collection) || !exactKeys(value.collection, ['enabled']) || typeof value.collection.enabled !== 'boolean') return false;
|
|
419
|
-
|
|
420
|
-
if (value.reporting.endpoint !== undefined) {
|
|
421
|
-
if (typeof value.reporting.endpoint !== 'string' || value.reporting.endpoint.length > 2048) return false;
|
|
422
|
-
try { if (!['http:', 'https:'].includes(new URL(value.reporting.endpoint).protocol)) return false; } catch { return false; }
|
|
423
|
-
}
|
|
424
|
-
if (value.reporting.credential_file !== undefined &&
|
|
425
|
-
(typeof value.reporting.credential_file !== 'string' || value.reporting.credential_file.length < 1 || value.reporting.credential_file.length > 4096)) return false;
|
|
426
|
-
return !value.reporting.enabled || (value.reporting.endpoint !== undefined && value.reporting.credential_file !== undefined);
|
|
448
|
+
return true;
|
|
427
449
|
}
|
|
428
450
|
|
|
429
451
|
function isPlainObject(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }
|