throughline 0.10.1 → 0.10.3

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.
@@ -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
+ });
@@ -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 });
@@ -1,7 +1,7 @@
1
1
  /**
2
- * hosts/identity.mjs — ベンダー (hook host) 識別の唯一の正本
2
+ * hosts/identity.mjs — harness (hook host) 識別の唯一の正本
3
3
  *
4
- * Throughline は Claude / Codex / Grok の 3 hook host を同じ SQLite に保存する。
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.deepEqual([...NON_CLAUDE_SESSION_PREFIXES], ['codex:', 'grok:']);
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('isCodexSessionId / isGrokSessionId reject non-strings', () => {
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
  });
@@ -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
- * 現状 camelCase envelope を送るのは Grok だけ (v0.10.0 / ADR 0021)。
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,25 @@
1
+ /**
2
+ * os/app-dirs.mjs — OS別のユーザー設定/状態ベースディレクトリ解決
3
+ *
4
+ * LOCALAPPDATA(Windows)と XDG_CONFIG_HOME / XDG_STATE_HOME(POSIX)の
5
+ * フォールバック組み立てが runtime-error-store と completed-turn-receipts に
6
+ * 別々に書かれていたため、ここへ集約する。最終的なアプリ別 join は呼び出し側が持つ。
7
+ */
8
+ import { homedir } from 'node:os';
9
+ import { join } from 'node:path';
10
+
11
+ function homeOf(env) {
12
+ return env.HOME || env.USERPROFILE || homedir();
13
+ }
14
+
15
+ export function windowsLocalAppData(env = process.env) {
16
+ return env.LOCALAPPDATA || join(homeOf(env), 'AppData', 'Local');
17
+ }
18
+
19
+ export function xdgConfigHome(env = process.env) {
20
+ return env.XDG_CONFIG_HOME || join(homeOf(env), '.config');
21
+ }
22
+
23
+ export function xdgStateHome(env = process.env) {
24
+ return env.XDG_STATE_HOME || join(homeOf(env), '.local', 'state');
25
+ }
package/src/os/paths.mjs CHANGED
@@ -10,3 +10,7 @@ import { platform } from 'node:os';
10
10
  export function foldPathCaseForPlatform(path, { hostPlatform = platform() } = {}) {
11
11
  return hostPlatform === 'win32' ? path.toLowerCase() : path;
12
12
  }
13
+
14
+ export function isWin32Platform(hostPlatform = platform()) {
15
+ return hostPlatform === 'win32';
16
+ }
@@ -10,12 +10,13 @@ import {
10
10
  writeFileSync,
11
11
  } from 'node:fs';
12
12
  import childProcess from 'node:child_process';
13
- import { arch as hostArch, homedir, platform as hostPlatform } from 'node:os';
13
+ import { arch as hostArch, platform as hostPlatform } from 'node:os';
14
14
  import { dirname, join } from 'node:path';
15
15
  import { createRequire } from 'node:module';
16
16
  import { fileURLToPath } from 'node:url';
17
17
  import { DatabaseSync } from 'node:sqlite';
18
18
  import { applyAndVerifyWindowsAcl, isWindows, verifyWindowsAcl } from './os/windows-acl.mjs';
19
+ import { windowsLocalAppData, xdgConfigHome, xdgStateHome } from './os/app-dirs.mjs';
19
20
 
20
21
  const require = createRequire(import.meta.url);
21
22
  const PACKAGE_VERSION = require('../package.json').version;
@@ -53,19 +54,17 @@ const DEFINITIONS = Object.freeze({
53
54
  });
54
55
 
55
56
  export function defaultFactoryReporterConfigPath(env = process.env) {
56
- const home = env.HOME || env.USERPROFILE || homedir();
57
57
  if (isWindows(env)) {
58
- return join(env.LOCALAPPDATA || join(home, 'AppData', 'Local'), 'dotagents', 'factory-reporter', 'config.json');
58
+ return join(windowsLocalAppData(env), 'dotagents', 'factory-reporter', 'config.json');
59
59
  }
60
- return join(env.XDG_CONFIG_HOME || join(home, '.config'), 'dotagents', 'factory-reporter.json');
60
+ return join(xdgConfigHome(env), 'dotagents', 'factory-reporter.json');
61
61
  }
62
62
 
63
63
  export function defaultRuntimeErrorStorePath(env = process.env) {
64
- const home = env.HOME || env.USERPROFILE || homedir();
65
64
  if (isWindows(env)) {
66
- return join(env.LOCALAPPDATA || join(home, 'AppData', 'Local'), 'throughline', 'runtime-errors.json');
65
+ return join(windowsLocalAppData(env), 'throughline', 'runtime-errors.json');
67
66
  }
68
- return join(env.XDG_STATE_HOME || join(home, '.local', 'state'), 'throughline', 'runtime-errors.json');
67
+ return join(xdgStateHome(env), 'throughline', 'runtime-errors.json');
69
68
  }
70
69
 
71
70
  export function isRuntimeErrorCollectionEnabled({ env = process.env, configPath } = {}) {
@@ -32,7 +32,8 @@ import { logDecision } from './decision-log.mjs';
32
32
  import { existsSync } from 'node:fs';
33
33
  import { pathToFileURL } from 'node:url';
34
34
  import { recordRuntimeErrorBestEffort } from './runtime-error-store.mjs';
35
- import { NON_CLAUDE_SESSION_PREFIXES, normalizeHookPayload } from './hosts/index.mjs';
35
+ import { hostAdapterForSessionId, NON_CLAUDE_SESSION_PREFIXES, normalizeHookPayload } from './hosts/index.mjs';
36
+ import { executeFirstPromptHandoff } from './handoff-executor.mjs';
36
37
 
37
38
  const ENV_DISABLE_AUTO_HANDOFF = 'THROUGHLINE_DISABLE_AUTO_HANDOFF';
38
39
 
@@ -161,6 +162,28 @@ export async function run() {
161
162
  pending_registered: true,
162
163
  });
163
164
 
165
+ // Cursor は beforeSubmitPrompt が additional_context を持たない。
166
+ // 新規 composer 会話の sessionStart が公式の注入口なので、ここで第二相を実行する。
167
+ // Claude の幽霊 SessionStart 問題はこの host では sessionStart = 会話作成のため適用しない。
168
+ // クラウド / background agent は baton を奪わない。
169
+ const hostAdapter = hostAdapterForSessionId(session_id);
170
+ if (hostAdapter.consumesHandoffAtSessionStart === true && payload.is_background_agent !== true) {
171
+ const handoff = executeFirstPromptHandoff(db, {
172
+ sessionId: session_id,
173
+ projectPath,
174
+ now,
175
+ });
176
+ if (handoff.attempted && handoff.injectionText) {
177
+ const delivery = hostAdapter.deliverHandoffInjection({
178
+ payload,
179
+ text: handoff.injectionText,
180
+ });
181
+ if (!delivery.delivered) {
182
+ process.stderr.write(`[session-start] ${delivery.reason}\n`);
183
+ }
184
+ }
185
+ }
186
+
164
187
  process.exit(0);
165
188
  }
166
189
 
@@ -0,0 +1,43 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+
7
+ import { getLogicalTurnGroups, readTranscript, sliceCurrentTurnEntries, readRawEntries } from './transcript-reader.mjs';
8
+
9
+ test('readTranscript accepts Cursor agent-transcripts role-first jsonl', () => {
10
+ const dir = mkdtempSync(join(tmpdir(), 'tl-cursor-transcript-'));
11
+ const path = join(dir, '7face712.jsonl');
12
+ writeFileSync(
13
+ path,
14
+ [
15
+ JSON.stringify({
16
+ role: 'user',
17
+ message: { content: [{ type: 'text', text: 'hello cursor' }] },
18
+ }),
19
+ JSON.stringify({
20
+ role: 'assistant',
21
+ message: { content: [{ type: 'text', text: 'captured reply' }] },
22
+ }),
23
+ ].join('\n'),
24
+ );
25
+ try {
26
+ const turns = readTranscript(path);
27
+ assert.deepEqual(
28
+ turns.map((t) => ({ role: t.role, content: t.content })),
29
+ [
30
+ { role: 'user', content: 'hello cursor' },
31
+ { role: 'assistant', content: 'captured reply' },
32
+ ],
33
+ );
34
+ const groups = getLogicalTurnGroups(path);
35
+ assert.equal(groups.length, 1);
36
+ assert.equal(groups[0].user.content, 'hello cursor');
37
+ assert.equal(groups[0].representative.content, 'captured reply');
38
+ const sliced = sliceCurrentTurnEntries(readRawEntries(path));
39
+ assert.equal(sliced.length, 2);
40
+ } finally {
41
+ rmSync(dir, { recursive: true, force: true });
42
+ }
43
+ });
@@ -11,6 +11,12 @@
11
11
  import { readFileSync, existsSync } from 'fs';
12
12
  import { DETAIL_KIND } from './constants.mjs';
13
13
 
14
+ function entryKind(entry) {
15
+ if (typeof entry?.type === 'string' && entry.type.length > 0) return entry.type;
16
+ if (typeof entry?.role === 'string' && entry.role.length > 0) return entry.role;
17
+ return undefined;
18
+ }
19
+
14
20
  /**
15
21
  * content 配列からテキスト部分だけを結合する。
16
22
  * thinking ブロックは除外。
@@ -51,8 +57,9 @@ export function readTranscript(transcriptPath) {
51
57
  continue;
52
58
  }
53
59
 
54
- // user / assistant エントリのみ対象
55
- if (entry.type !== 'user' && entry.type !== 'assistant') continue;
60
+ // user / assistant エントリのみ対象。Claude は type、Cursor jsonl は role。
61
+ const kind = entryKind(entry);
62
+ if (kind !== 'user' && kind !== 'assistant') continue;
56
63
 
57
64
  // subagent の sidechain エントリは主会話ではないので除外。現行 CC は主 transcript に
58
65
  // 書かない(400 transcript 実測ゼロ件)が、将来変更への安価な防御 (docs/12 B-1)
@@ -60,7 +67,7 @@ export function readTranscript(transcriptPath) {
60
67
 
61
68
  const msg = entry.message;
62
69
  const grokContent = entry.content;
63
- const role = msg?.role ?? entry.type;
70
+ const role = msg?.role ?? kind;
64
71
  const rawContent = msg?.content ?? grokContent;
65
72
  if (!role || rawContent == null) continue;
66
73
 
@@ -258,7 +265,7 @@ export function sliceCurrentTurnEntries(entries) {
258
265
  let lastAssistantTextIdx = -1;
259
266
  for (let i = entries.length - 1; i >= 0; i--) {
260
267
  const e = entries[i];
261
- if (e.type !== 'assistant') continue;
268
+ if (entryKind(e) !== 'assistant') continue;
262
269
  const blocks = e.message?.content;
263
270
  if (!Array.isArray(blocks)) continue;
264
271
  if (blocks.some((b) => b && b.type === 'text' && typeof b.text === 'string' && b.text.length > 0)) {
@@ -272,7 +279,7 @@ export function sliceCurrentTurnEntries(entries) {
272
279
  let userTextIdx = -1;
273
280
  for (let i = lastAssistantTextIdx - 1; i >= 0; i--) {
274
281
  const e = entries[i];
275
- if (e.type !== 'user') continue;
282
+ if (entryKind(e) !== 'user') continue;
276
283
  const blocks = e.message?.content;
277
284
  if (Array.isArray(blocks)) {
278
285
  if (blocks.some((b) => b && b.type === 'text' && typeof b.text === 'string' && b.text.length > 0)) {
@@ -319,7 +326,7 @@ export function extractDetailBlocks(turnEntries) {
319
326
  const toolNameById = new Map();
320
327
 
321
328
  for (const e of turnEntries) {
322
- if (e.type === 'assistant') {
329
+ if (entryKind(e) === 'assistant') {
323
330
  const blocks = e.message?.content;
324
331
  if (!Array.isArray(blocks)) continue;
325
332
  for (let i = 0; i < blocks.length; i++) {
@@ -355,7 +362,7 @@ export function extractDetailBlocks(turnEntries) {
355
362
  }
356
363
  // text は扱わない
357
364
  }
358
- } else if (e.type === 'user') {
365
+ } else if (entryKind(e) === 'user') {
359
366
  const blocks = e.message?.content;
360
367
  if (!Array.isArray(blocks)) continue;
361
368
  for (const b of blocks) {