@doingdev/opencode-claude-manager-plugin 0.1.64 → 0.1.66

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 (125) hide show
  1. package/README.md +106 -120
  2. package/dist/claude/claude-agent-sdk-adapter.js +1 -1
  3. package/dist/index.d.ts +1 -1
  4. package/dist/manager/team-orchestrator.js +1 -1
  5. package/dist/plugin/agents/common.d.ts +2 -2
  6. package/dist/plugin/agents/common.js +5 -0
  7. package/dist/plugin/claude-manager.plugin.js +104 -0
  8. package/dist/plugin/inbox-ops.d.ts +50 -0
  9. package/dist/plugin/inbox-ops.js +166 -0
  10. package/dist/types/contracts.d.ts +18 -0
  11. package/package.json +13 -13
  12. package/dist/claude/session-live-tailer.d.ts +0 -51
  13. package/dist/claude/session-live-tailer.js +0 -269
  14. package/dist/manager/session-controller.d.ts +0 -41
  15. package/dist/manager/session-controller.js +0 -97
  16. package/dist/metadata/claude-metadata.service.d.ts +0 -12
  17. package/dist/metadata/claude-metadata.service.js +0 -38
  18. package/dist/metadata/repo-claude-config-reader.d.ts +0 -7
  19. package/dist/metadata/repo-claude-config-reader.js +0 -154
  20. package/dist/plugin/orchestrator.plugin.d.ts +0 -2
  21. package/dist/plugin/orchestrator.plugin.js +0 -116
  22. package/dist/providers/claude-code-wrapper.d.ts +0 -13
  23. package/dist/providers/claude-code-wrapper.js +0 -13
  24. package/dist/safety/bash-safety.d.ts +0 -21
  25. package/dist/safety/bash-safety.js +0 -62
  26. package/dist/src/claude/claude-agent-sdk-adapter.d.ts +0 -28
  27. package/dist/src/claude/claude-agent-sdk-adapter.js +0 -559
  28. package/dist/src/claude/claude-session.service.d.ts +0 -9
  29. package/dist/src/claude/claude-session.service.js +0 -15
  30. package/dist/src/claude/session-live-tailer.d.ts +0 -51
  31. package/dist/src/claude/session-live-tailer.js +0 -269
  32. package/dist/src/claude/tool-approval-manager.d.ts +0 -30
  33. package/dist/src/claude/tool-approval-manager.js +0 -279
  34. package/dist/src/index.d.ts +0 -5
  35. package/dist/src/index.js +0 -3
  36. package/dist/src/manager/context-tracker.d.ts +0 -32
  37. package/dist/src/manager/context-tracker.js +0 -103
  38. package/dist/src/manager/git-operations.d.ts +0 -18
  39. package/dist/src/manager/git-operations.js +0 -86
  40. package/dist/src/manager/persistent-manager.d.ts +0 -39
  41. package/dist/src/manager/persistent-manager.js +0 -44
  42. package/dist/src/manager/session-controller.d.ts +0 -41
  43. package/dist/src/manager/session-controller.js +0 -97
  44. package/dist/src/manager/team-orchestrator.d.ts +0 -81
  45. package/dist/src/manager/team-orchestrator.js +0 -612
  46. package/dist/src/plugin/agent-hierarchy.d.ts +0 -1
  47. package/dist/src/plugin/agent-hierarchy.js +0 -2
  48. package/dist/src/plugin/agents/browser-qa.d.ts +0 -14
  49. package/dist/src/plugin/agents/browser-qa.js +0 -31
  50. package/dist/src/plugin/agents/common.d.ts +0 -36
  51. package/dist/src/plugin/agents/common.js +0 -59
  52. package/dist/src/plugin/agents/cto.d.ts +0 -9
  53. package/dist/src/plugin/agents/cto.js +0 -39
  54. package/dist/src/plugin/agents/engineers.d.ts +0 -9
  55. package/dist/src/plugin/agents/engineers.js +0 -11
  56. package/dist/src/plugin/agents/index.d.ts +0 -5
  57. package/dist/src/plugin/agents/index.js +0 -5
  58. package/dist/src/plugin/agents/team-planner.d.ts +0 -10
  59. package/dist/src/plugin/agents/team-planner.js +0 -23
  60. package/dist/src/plugin/claude-manager.plugin.d.ts +0 -10
  61. package/dist/src/plugin/claude-manager.plugin.js +0 -950
  62. package/dist/src/plugin/service-factory.d.ts +0 -38
  63. package/dist/src/plugin/service-factory.js +0 -101
  64. package/dist/src/prompts/registry.d.ts +0 -2
  65. package/dist/src/prompts/registry.js +0 -210
  66. package/dist/src/state/file-run-state-store.d.ts +0 -14
  67. package/dist/src/state/file-run-state-store.js +0 -85
  68. package/dist/src/state/team-state-store.d.ts +0 -14
  69. package/dist/src/state/team-state-store.js +0 -88
  70. package/dist/src/state/transcript-store.d.ts +0 -15
  71. package/dist/src/state/transcript-store.js +0 -44
  72. package/dist/src/team/roster.d.ts +0 -5
  73. package/dist/src/team/roster.js +0 -40
  74. package/dist/src/types/contracts.d.ts +0 -261
  75. package/dist/src/types/contracts.js +0 -2
  76. package/dist/src/util/fs-helpers.d.ts +0 -8
  77. package/dist/src/util/fs-helpers.js +0 -21
  78. package/dist/src/util/project-context.d.ts +0 -10
  79. package/dist/src/util/project-context.js +0 -105
  80. package/dist/src/util/transcript-append.d.ts +0 -7
  81. package/dist/src/util/transcript-append.js +0 -29
  82. package/dist/state/file-run-state-store.d.ts +0 -14
  83. package/dist/state/file-run-state-store.js +0 -85
  84. package/dist/test/claude-agent-sdk-adapter.test.d.ts +0 -1
  85. package/dist/test/claude-agent-sdk-adapter.test.js +0 -707
  86. package/dist/test/claude-manager.plugin.test.d.ts +0 -1
  87. package/dist/test/claude-manager.plugin.test.js +0 -316
  88. package/dist/test/context-tracker.test.d.ts +0 -1
  89. package/dist/test/context-tracker.test.js +0 -130
  90. package/dist/test/cto-active-team.test.d.ts +0 -1
  91. package/dist/test/cto-active-team.test.js +0 -199
  92. package/dist/test/file-run-state-store.test.d.ts +0 -1
  93. package/dist/test/file-run-state-store.test.js +0 -82
  94. package/dist/test/fs-helpers.test.d.ts +0 -1
  95. package/dist/test/fs-helpers.test.js +0 -56
  96. package/dist/test/git-operations.test.d.ts +0 -1
  97. package/dist/test/git-operations.test.js +0 -133
  98. package/dist/test/persistent-manager.test.d.ts +0 -1
  99. package/dist/test/persistent-manager.test.js +0 -48
  100. package/dist/test/project-context.test.d.ts +0 -1
  101. package/dist/test/project-context.test.js +0 -92
  102. package/dist/test/prompt-registry.test.d.ts +0 -1
  103. package/dist/test/prompt-registry.test.js +0 -117
  104. package/dist/test/report-claude-event.test.d.ts +0 -1
  105. package/dist/test/report-claude-event.test.js +0 -304
  106. package/dist/test/session-controller.test.d.ts +0 -1
  107. package/dist/test/session-controller.test.js +0 -149
  108. package/dist/test/session-live-tailer.test.d.ts +0 -1
  109. package/dist/test/session-live-tailer.test.js +0 -313
  110. package/dist/test/team-orchestrator.test.d.ts +0 -1
  111. package/dist/test/team-orchestrator.test.js +0 -583
  112. package/dist/test/team-state-store.test.d.ts +0 -1
  113. package/dist/test/team-state-store.test.js +0 -54
  114. package/dist/test/tool-approval-manager.test.d.ts +0 -1
  115. package/dist/test/tool-approval-manager.test.js +0 -260
  116. package/dist/test/transcript-append.test.d.ts +0 -1
  117. package/dist/test/transcript-append.test.js +0 -37
  118. package/dist/test/transcript-store.test.d.ts +0 -1
  119. package/dist/test/transcript-store.test.js +0 -50
  120. package/dist/test/undo-propagation.test.d.ts +0 -1
  121. package/dist/test/undo-propagation.test.js +0 -837
  122. package/dist/util/project-context.d.ts +0 -10
  123. package/dist/util/project-context.js +0 -105
  124. package/dist/vitest.config.d.ts +0 -2
  125. package/dist/vitest.config.js +0 -11
@@ -0,0 +1,166 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ export const MAX_INBOX_SIZE = 50;
3
+ export const MAX_MESSAGE_BODY_LENGTH = 4000;
4
+ export const MAX_MESSAGE_SUBJECT_LENGTH = 200;
5
+ /**
6
+ * Maximum reply-chain depth. A top-level message has depth 0; each reply
7
+ * increments by 1. Attempts to exceed this cap are rejected with a message
8
+ * directing the agent to loop in the CTO.
9
+ */
10
+ export const MAX_THREAD_DEPTH = 5;
11
+ /**
12
+ * Deliver a peer message from one engineer to another on the same team.
13
+ * All validation and persistence happen atomically via the write queue.
14
+ */
15
+ export async function sendMessage(store, cwd, teamId, fromEngineer, toEngineer, subject, body, options = {}) {
16
+ if (fromEngineer === toEngineer) {
17
+ return { ok: false, error: 'Cannot send a message to yourself.' };
18
+ }
19
+ if (subject.length > MAX_MESSAGE_SUBJECT_LENGTH) {
20
+ return { ok: false, error: `Subject exceeds ${MAX_MESSAGE_SUBJECT_LENGTH} characters.` };
21
+ }
22
+ if (body.length > MAX_MESSAGE_BODY_LENGTH) {
23
+ return { ok: false, error: `Body exceeds ${MAX_MESSAGE_BODY_LENGTH} characters.` };
24
+ }
25
+ const { filePaths, replyToId } = options;
26
+ if (filePaths?.some((p) => !p.trim())) {
27
+ return { ok: false, error: 'File paths must not be empty strings.' };
28
+ }
29
+ let sent = null;
30
+ let updateError = null;
31
+ try {
32
+ await store.updateTeam(cwd, teamId, (team) => {
33
+ const recipient = team.engineers.find((e) => e.name === toEngineer);
34
+ if (!recipient) {
35
+ throw new Error(`Engineer ${toEngineer} is not on team ${teamId}.`);
36
+ }
37
+ const inbox = recipient.inbox ?? [];
38
+ const unreadCount = inbox.filter((m) => m.status === 'unread').length;
39
+ if (unreadCount >= MAX_INBOX_SIZE) {
40
+ throw new Error(`${toEngineer}'s inbox is full (${MAX_INBOX_SIZE} unread messages).`);
41
+ }
42
+ // Thread-depth guard: resolve depth from the replied-to message.
43
+ let depth = 0;
44
+ if (replyToId !== undefined) {
45
+ const senderRecord = team.engineers.find((e) => e.name === fromEngineer);
46
+ const original = (senderRecord?.inbox ?? []).find((m) => m.id === replyToId);
47
+ if (!original) {
48
+ throw new Error(`Reply-to message ${replyToId} not found in ${fromEngineer}'s inbox.`);
49
+ }
50
+ if (original.toEngineer !== fromEngineer) {
51
+ throw new Error('replyToId must reference a message addressed to you.');
52
+ }
53
+ depth = original.depth + 1;
54
+ if (depth > MAX_THREAD_DEPTH) {
55
+ throw new Error(`Thread depth limit reached (max ${MAX_THREAD_DEPTH}). Loop in the CTO to coordinate further.`);
56
+ }
57
+ }
58
+ const msg = {
59
+ id: randomUUID(),
60
+ fromEngineer,
61
+ toEngineer,
62
+ teamId,
63
+ createdAt: new Date().toISOString(),
64
+ status: 'unread',
65
+ subject: subject.trim(),
66
+ body,
67
+ depth,
68
+ ...(replyToId !== undefined && { replyToId }),
69
+ ...(filePaths !== undefined && filePaths.length > 0 && { filePaths }),
70
+ };
71
+ sent = msg;
72
+ return {
73
+ ...team,
74
+ updatedAt: new Date().toISOString(),
75
+ engineers: team.engineers.map((e) => e.name === toEngineer ? { ...e, inbox: [...inbox, msg] } : e),
76
+ };
77
+ });
78
+ }
79
+ catch (error) {
80
+ updateError = error instanceof Error ? error.message : String(error);
81
+ }
82
+ if (updateError !== null) {
83
+ return { ok: false, error: updateError };
84
+ }
85
+ if (!sent) {
86
+ return { ok: false, error: 'Failed to persist message.' };
87
+ }
88
+ return { ok: true, message: sent };
89
+ }
90
+ /**
91
+ * Return all unread messages for an engineer, in FIFO order.
92
+ * Acked messages are excluded.
93
+ */
94
+ export async function listInbox(store, cwd, teamId, engineerName) {
95
+ const team = await store.getTeam(cwd, teamId);
96
+ if (!team)
97
+ return [];
98
+ const engineer = team.engineers.find((e) => e.name === engineerName);
99
+ if (!engineer)
100
+ return [];
101
+ return (engineer.inbox ?? []).filter((m) => m.status === 'unread');
102
+ }
103
+ /** Fetch a single message by ID. Returns null if not found or if it belongs to a different engineer. */
104
+ export async function getMessage(store, cwd, teamId, engineerName, messageId) {
105
+ const team = await store.getTeam(cwd, teamId);
106
+ if (!team)
107
+ return null;
108
+ const engineer = team.engineers.find((e) => e.name === engineerName);
109
+ if (!engineer)
110
+ return null;
111
+ return (engineer.inbox ?? []).find((m) => m.id === messageId) ?? null;
112
+ }
113
+ /**
114
+ * Mark a message as acked (hidden from list_inbox).
115
+ * Returns true if the ack was applied, false if the message was not found or already acked.
116
+ */
117
+ export async function ackMessage(store, cwd, teamId, engineerName, messageId) {
118
+ let acked = false;
119
+ try {
120
+ await store.updateTeam(cwd, teamId, (team) => {
121
+ const engineer = team.engineers.find((e) => e.name === engineerName);
122
+ if (!engineer)
123
+ return team;
124
+ const inbox = engineer.inbox ?? [];
125
+ const msg = inbox.find((m) => m.id === messageId);
126
+ if (!msg || msg.status === 'acked')
127
+ return team;
128
+ acked = true;
129
+ return {
130
+ ...team,
131
+ updatedAt: new Date().toISOString(),
132
+ engineers: team.engineers.map((e) => e.name === engineerName
133
+ ? {
134
+ ...e,
135
+ inbox: inbox.map((m) => m.id === messageId ? { ...m, status: 'acked' } : m),
136
+ }
137
+ : e),
138
+ };
139
+ });
140
+ }
141
+ catch {
142
+ return false;
143
+ }
144
+ return acked;
145
+ }
146
+ /** Count unread messages for one engineer without hitting the store. */
147
+ export function getUnreadCount(team, engineerName) {
148
+ const engineer = team.engineers.find((e) => e.name === engineerName);
149
+ return (engineer?.inbox ?? []).filter((m) => m.status === 'unread').length;
150
+ }
151
+ /** Build a concise notice to inject into the engineer's system prompt. */
152
+ export function buildInboxNotice(unreadCount) {
153
+ const plural = unreadCount === 1 ? '' : 's';
154
+ return `You have ${unreadCount} unread peer message${plural}. Use list_inbox to view them, get_message to read a message body, and ack_message to hide a message from your inbox.`;
155
+ }
156
+ /** Return all inboxes on a team (including acked messages) for CTO inspection. */
157
+ export function listAllInboxes(team) {
158
+ const result = {};
159
+ for (const engineer of team.engineers) {
160
+ const inbox = engineer.inbox ?? [];
161
+ if (inbox.length > 0) {
162
+ result[engineer.name] = inbox;
163
+ }
164
+ }
165
+ return result;
166
+ }
@@ -27,6 +27,23 @@ export interface WrapperHistoryEntry {
27
27
  mode?: EngineerWorkMode;
28
28
  text: string;
29
29
  }
30
+ export type AgentMessageStatus = 'unread' | 'acked';
31
+ export interface AgentMessage {
32
+ id: string;
33
+ fromEngineer: EngineerName;
34
+ toEngineer: EngineerName;
35
+ teamId: string;
36
+ createdAt: string;
37
+ status: AgentMessageStatus;
38
+ subject: string;
39
+ body: string;
40
+ /** 0-based chain depth. Top-level messages are 0; each reply increments by 1. */
41
+ depth: number;
42
+ /** ID of the message being replied to, if this is a reply. */
43
+ replyToId?: string;
44
+ /** File paths referenced in this message. Contents are not embedded — paths only. */
45
+ filePaths?: string[];
46
+ }
30
47
  interface ClaudeCommandMetadata {
31
48
  name: string;
32
49
  description: string;
@@ -145,6 +162,7 @@ export interface TeamEngineerRecord {
145
162
  lastUsedAt: string | null;
146
163
  wrapperHistory: WrapperHistoryEntry[];
147
164
  context: SessionContextSnapshot;
165
+ inbox?: AgentMessage[];
148
166
  }
149
167
  export type EngineerFailureKind = 'sdkError' | 'contextExhausted' | 'toolDenied' | 'modeNotSupported' | 'aborted' | 'engineerBusy' | 'unknown';
150
168
  export interface EngineerFailureResult {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doingdev/opencode-claude-manager-plugin",
3
- "version": "0.1.64",
3
+ "version": "0.1.66",
4
4
  "description": "OpenCode plugin that orchestrates Claude Code sessions.",
5
5
  "keywords": [
6
6
  "opencode",
@@ -29,21 +29,21 @@
29
29
  "node": ">=22.0.0"
30
30
  },
31
31
  "dependencies": {
32
- "@anthropic-ai/claude-agent-sdk": "^0.2.81",
33
- "@opencode-ai/plugin": "^1.2.27",
34
- "zod": "^4.1.8"
32
+ "@anthropic-ai/claude-agent-sdk": "^0.2.89",
33
+ "@opencode-ai/plugin": "^1.3.13",
34
+ "zod": "^4.3.6"
35
35
  },
36
36
  "devDependencies": {
37
- "@eslint/js": "^9.22.0",
38
- "@types/node": "^24.5.2",
39
- "@vitest/coverage-v8": "^4.1.0",
40
- "eslint": "^9.22.0",
41
- "globals": "^15.15.0",
42
- "knip": "^6.0.2",
37
+ "@eslint/js": "^10.0.1",
38
+ "@types/node": "^25.5.0",
39
+ "@vitest/coverage-v8": "^4.1.2",
40
+ "eslint": "^10.1.0",
41
+ "globals": "^17.4.0",
42
+ "knip": "^6.1.1",
43
43
  "prettier": "^3.8.1",
44
- "typescript": "^5.9.3",
45
- "typescript-eslint": "^8.57.1",
46
- "vitest": "^4.1.0"
44
+ "typescript": "^6.0.2",
45
+ "typescript-eslint": "^8.58.0",
46
+ "vitest": "^4.1.2"
47
47
  },
48
48
  "scripts": {
49
49
  "build": "tsc -p tsconfig.build.json",
@@ -1,51 +0,0 @@
1
- import type { LiveTailEvent, ToolOutputPreview } from '../types/contracts.js';
2
- /**
3
- * Tails Claude Code session JSONL files for live tool output.
4
- *
5
- * The SDK streams high-level events (assistant text, tool_call summaries, results)
6
- * but does not expose the raw tool output that Claude Code writes to the JSONL
7
- * transcript on disk. This service fills that gap by polling the file for new
8
- * lines, parsing each one, and forwarding parsed events to a caller-supplied
9
- * callback.
10
- */
11
- export declare class SessionLiveTailer {
12
- private activeTails;
13
- /**
14
- * Locate the JSONL file for a session.
15
- *
16
- * Claude Code stores transcripts at:
17
- * ~/.claude/projects/<sanitized-cwd>/sessions/<session-id>.jsonl
18
- *
19
- * The `<sanitized-cwd>` folder name is an internal implementation detail that
20
- * can change across Claude Code versions, so we search all project directories
21
- * for the session file rather than attempting to replicate the sanitisation.
22
- */
23
- findSessionFile(sessionId: string, cwd?: string): string | null;
24
- /**
25
- * Check whether we can locate a JSONL file for the given session.
26
- */
27
- sessionFileExists(sessionId: string, cwd?: string): boolean;
28
- /**
29
- * Poll a session's JSONL file for new lines and emit parsed events.
30
- *
31
- * Returns a stop function. The caller **must** invoke it when tailing is no
32
- * longer needed (e.g. when the session completes or the tool is interrupted).
33
- */
34
- startTailing(sessionId: string, cwd: string | undefined, onEvent: (event: LiveTailEvent) => void, pollIntervalMs?: number): () => void;
35
- /**
36
- * Stop tailing a specific session.
37
- */
38
- stopTailing(sessionId: string): void;
39
- /**
40
- * Stop all active tails (cleanup on shutdown).
41
- */
42
- stopAll(): void;
43
- /**
44
- * Read the last N lines from a session JSONL file.
45
- */
46
- getLastLines(sessionId: string, cwd: string | undefined, lineCount?: number): Promise<string[]>;
47
- /**
48
- * Extract a preview of recent tool output from the tail of a session file.
49
- */
50
- getToolOutputPreview(sessionId: string, cwd: string | undefined, maxEntries?: number): Promise<ToolOutputPreview[]>;
51
- }
@@ -1,269 +0,0 @@
1
- import { createReadStream, existsSync, readdirSync, statSync } from 'node:fs';
2
- import { createInterface } from 'node:readline';
3
- import path from 'node:path';
4
- import os from 'node:os';
5
- /**
6
- * Tails Claude Code session JSONL files for live tool output.
7
- *
8
- * The SDK streams high-level events (assistant text, tool_call summaries, results)
9
- * but does not expose the raw tool output that Claude Code writes to the JSONL
10
- * transcript on disk. This service fills that gap by polling the file for new
11
- * lines, parsing each one, and forwarding parsed events to a caller-supplied
12
- * callback.
13
- */
14
- export class SessionLiveTailer {
15
- activeTails = new Map();
16
- // ── Path discovery ──────────────────────────────────────────────────
17
- /**
18
- * Locate the JSONL file for a session.
19
- *
20
- * Claude Code stores transcripts at:
21
- * ~/.claude/projects/<sanitized-cwd>/sessions/<session-id>.jsonl
22
- *
23
- * The `<sanitized-cwd>` folder name is an internal implementation detail that
24
- * can change across Claude Code versions, so we search all project directories
25
- * for the session file rather than attempting to replicate the sanitisation.
26
- */
27
- findSessionFile(sessionId, cwd) {
28
- const projectsDir = path.join(os.homedir(), '.claude', 'projects');
29
- if (!existsSync(projectsDir)) {
30
- return null;
31
- }
32
- // If cwd is provided, try the sanitised form first (best-effort fast path).
33
- if (cwd) {
34
- const sanitised = cwd.replace(/\//g, '-');
35
- const candidate = path.join(projectsDir, sanitised, 'sessions', `${sessionId}.jsonl`);
36
- if (existsSync(candidate)) {
37
- return candidate;
38
- }
39
- }
40
- // Fall back to scanning all project directories.
41
- try {
42
- for (const entry of readdirSync(projectsDir, { withFileTypes: true })) {
43
- if (!entry.isDirectory())
44
- continue;
45
- const candidate = path.join(projectsDir, entry.name, 'sessions', `${sessionId}.jsonl`);
46
- if (existsSync(candidate)) {
47
- return candidate;
48
- }
49
- }
50
- }
51
- catch {
52
- // Permission denied or similar — nothing we can do.
53
- }
54
- return null;
55
- }
56
- /**
57
- * Check whether we can locate a JSONL file for the given session.
58
- */
59
- sessionFileExists(sessionId, cwd) {
60
- return this.findSessionFile(sessionId, cwd) !== null;
61
- }
62
- // ── Live tailing ────────────────────────────────────────────────────
63
- /**
64
- * Poll a session's JSONL file for new lines and emit parsed events.
65
- *
66
- * Returns a stop function. The caller **must** invoke it when tailing is no
67
- * longer needed (e.g. when the session completes or the tool is interrupted).
68
- */
69
- startTailing(sessionId, cwd, onEvent, pollIntervalMs = 150) {
70
- const filePath = this.findSessionFile(sessionId, cwd);
71
- if (!filePath) {
72
- onEvent({
73
- type: 'error',
74
- sessionId,
75
- error: `Session JSONL not found for ${sessionId}`,
76
- });
77
- return () => { };
78
- }
79
- let offset = statSync(filePath).size;
80
- let buffer = '';
81
- let reading = false; // guard against overlapping reads
82
- const interval = setInterval(() => {
83
- if (reading)
84
- return;
85
- try {
86
- const currentSize = statSync(filePath).size;
87
- if (currentSize < offset) {
88
- // File was truncated (rotation / compaction) — reset.
89
- offset = 0;
90
- buffer = '';
91
- }
92
- if (currentSize <= offset)
93
- return;
94
- reading = true;
95
- const stream = createReadStream(filePath, {
96
- start: offset,
97
- end: currentSize - 1,
98
- encoding: 'utf8',
99
- });
100
- let chunk = '';
101
- stream.on('data', (data) => {
102
- chunk += typeof data === 'string' ? data : data.toString('utf8');
103
- });
104
- stream.on('end', () => {
105
- reading = false;
106
- offset = currentSize;
107
- buffer += chunk;
108
- const lines = buffer.split('\n');
109
- // Keep the last (possibly incomplete) segment in the buffer.
110
- buffer = lines.pop() ?? '';
111
- for (const line of lines) {
112
- const trimmed = line.trim();
113
- if (!trimmed)
114
- continue;
115
- try {
116
- const parsed = JSON.parse(trimmed);
117
- onEvent({
118
- type: 'line',
119
- sessionId,
120
- data: parsed,
121
- rawLine: trimmed,
122
- });
123
- }
124
- catch {
125
- onEvent({
126
- type: 'line',
127
- sessionId,
128
- data: null,
129
- rawLine: trimmed,
130
- });
131
- }
132
- }
133
- });
134
- stream.on('error', (err) => {
135
- reading = false;
136
- onEvent({
137
- type: 'error',
138
- sessionId,
139
- error: err.message,
140
- });
141
- });
142
- }
143
- catch (err) {
144
- reading = false;
145
- onEvent({
146
- type: 'error',
147
- sessionId,
148
- error: err instanceof Error ? err.message : String(err),
149
- });
150
- }
151
- }, pollIntervalMs);
152
- this.activeTails.set(sessionId, interval);
153
- return () => this.stopTailing(sessionId);
154
- }
155
- /**
156
- * Stop tailing a specific session.
157
- */
158
- stopTailing(sessionId) {
159
- const interval = this.activeTails.get(sessionId);
160
- if (interval) {
161
- clearInterval(interval);
162
- this.activeTails.delete(sessionId);
163
- }
164
- }
165
- /**
166
- * Stop all active tails (cleanup on shutdown).
167
- */
168
- stopAll() {
169
- for (const [, interval] of this.activeTails) {
170
- clearInterval(interval);
171
- }
172
- this.activeTails.clear();
173
- }
174
- // ── Snapshot helpers ────────────────────────────────────────────────
175
- /**
176
- * Read the last N lines from a session JSONL file.
177
- */
178
- async getLastLines(sessionId, cwd, lineCount = 20) {
179
- const filePath = this.findSessionFile(sessionId, cwd);
180
- if (!filePath)
181
- return [];
182
- const lines = [];
183
- const rl = createInterface({
184
- input: createReadStream(filePath, { encoding: 'utf8' }),
185
- });
186
- for await (const line of rl) {
187
- lines.push(line);
188
- if (lines.length > lineCount) {
189
- lines.shift();
190
- }
191
- }
192
- return lines;
193
- }
194
- /**
195
- * Extract a preview of recent tool output from the tail of a session file.
196
- */
197
- async getToolOutputPreview(sessionId, cwd, maxEntries = 5) {
198
- const lastLines = await this.getLastLines(sessionId, cwd, 100);
199
- const previews = [];
200
- for (const line of lastLines) {
201
- const trimmed = line.trim();
202
- if (!trimmed)
203
- continue;
204
- try {
205
- const parsed = JSON.parse(trimmed);
206
- const preview = extractToolOutput(parsed);
207
- if (preview) {
208
- previews.push(preview);
209
- }
210
- }
211
- catch {
212
- // skip unparseable lines
213
- }
214
- }
215
- return previews.slice(-maxEntries);
216
- }
217
- }
218
- // ── Internal helpers ────────────────────────────────────────────────────
219
- /**
220
- * Attempt to extract tool output information from a JSONL record.
221
- *
222
- * Claude Code JSONL records with tool results can appear as:
223
- * 1. A top-level `{ type: "user", message: { content: [{ type: "tool_result", ... }] } }`
224
- * 2. Direct `tool_result` entries (less common).
225
- */
226
- function extractToolOutput(record) {
227
- // Pattern 1: user message wrapping tool_result content blocks
228
- if (record.type === 'user') {
229
- const message = record.message;
230
- if (!message)
231
- return null;
232
- const content = message.content;
233
- if (!Array.isArray(content))
234
- return null;
235
- for (const block of content) {
236
- if (block &&
237
- typeof block === 'object' &&
238
- block.type === 'tool_result') {
239
- const b = block;
240
- return {
241
- toolUseId: typeof b.tool_use_id === 'string' ? b.tool_use_id : '',
242
- content: stringifyContent(b.content),
243
- isError: b.is_error === true,
244
- };
245
- }
246
- }
247
- }
248
- // Pattern 2: direct tool_result record (varies by Claude Code version)
249
- if (record.type === 'tool_result') {
250
- return {
251
- toolUseId: typeof record.tool_use_id === 'string' ? record.tool_use_id : '',
252
- content: stringifyContent(record.content),
253
- isError: record.is_error === true,
254
- };
255
- }
256
- return null;
257
- }
258
- function stringifyContent(value) {
259
- if (typeof value === 'string')
260
- return value;
261
- if (value === undefined || value === null)
262
- return '';
263
- try {
264
- return JSON.stringify(value);
265
- }
266
- catch {
267
- return '[non-serializable]';
268
- }
269
- }
@@ -1,41 +0,0 @@
1
- import type { ClaudeAgentSdkAdapter, ClaudeSessionEventHandler } from '../claude/claude-agent-sdk-adapter.js';
2
- import type { ClaudeSessionRunResult, SessionContextSnapshot, SessionMode } from '../types/contracts.js';
3
- import type { ContextTracker } from './context-tracker.js';
4
- export declare class SessionController {
5
- private readonly sdkAdapter;
6
- private readonly contextTracker;
7
- private readonly sessionPrompt;
8
- private readonly wrapperType;
9
- private readonly worktree;
10
- private readonly modePrefixes;
11
- private activeSessionId;
12
- constructor(sdkAdapter: ClaudeAgentSdkAdapter, contextTracker: ContextTracker, sessionPrompt: string | undefined, wrapperType: string, worktree: string, modePrefixes?: {
13
- plan: string;
14
- free: string;
15
- });
16
- get isActive(): boolean;
17
- get sessionId(): string | null;
18
- /**
19
- * Send a message to the persistent session. Creates one if none exists.
20
- * Returns the session result including usage data.
21
- */
22
- sendMessage(message: string, options?: {
23
- model?: string;
24
- effort?: 'low' | 'medium' | 'high' | 'max';
25
- mode?: SessionMode;
26
- sessionSystemPrompt?: string;
27
- abortSignal?: AbortSignal;
28
- }, onEvent?: ClaudeSessionEventHandler): Promise<ClaudeSessionRunResult>;
29
- /**
30
- * Send /compact to the current session to compress context.
31
- */
32
- compactSession(onEvent?: ClaudeSessionEventHandler): Promise<ClaudeSessionRunResult>;
33
- /**
34
- * Clear the current session. The next sendMessage will create a fresh one.
35
- */
36
- clearSession(): Promise<string | null>;
37
- /**
38
- * Get current context tracking snapshot.
39
- */
40
- getContextSnapshot(): SessionContextSnapshot;
41
- }