@excitedjs/dreamux 0.13.0 → 0.14.0
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.json +41 -0
- package/CHANGELOG.md +16 -1
- package/README.md +7 -4
- package/dist/admin/methods.js +86 -61
- package/dist/admin/methods.js.map +1 -1
- package/dist/admin/socket.js +31 -0
- package/dist/admin/socket.js.map +1 -1
- package/dist/agent-runtime/builtin/claude-code/runtime.js +8 -3
- package/dist/agent-runtime/builtin/claude-code/runtime.js.map +1 -1
- package/dist/agent-runtime/builtin/claude-code/supervisor.js +5 -0
- package/dist/agent-runtime/builtin/claude-code/supervisor.js.map +1 -1
- package/dist/agent-runtime/builtin/codex/codex-home.js +2 -3
- package/dist/agent-runtime/builtin/codex/codex-home.js.map +1 -1
- package/dist/agent-runtime/builtin/codex/paths.js +16 -62
- package/dist/agent-runtime/builtin/codex/paths.js.map +1 -1
- package/dist/agent-runtime/builtin/codex/runtime-support.js +4 -3
- package/dist/agent-runtime/builtin/codex/runtime-support.js.map +1 -1
- package/dist/agent-runtime/builtin/codex/runtime.js +5 -6
- package/dist/agent-runtime/builtin/codex/runtime.js.map +1 -1
- package/dist/agent-runtime/builtin/codex/supervisor.js +11 -1
- package/dist/agent-runtime/builtin/codex/supervisor.js.map +1 -1
- package/dist/agent-runtime/completion-body.js +14 -9
- package/dist/agent-runtime/completion-body.js.map +1 -1
- package/dist/channel/feishu/bot.js +0 -18
- package/dist/channel/feishu/bot.js.map +1 -1
- package/dist/channel/feishu/feishu-message.js +9 -2
- package/dist/channel/feishu/feishu-message.js.map +1 -1
- package/dist/cli/doctor.js +23 -0
- package/dist/cli/doctor.js.map +1 -1
- package/dist/cli/dreamux.js +1 -1
- package/dist/cli/dreamux.js.map +1 -1
- package/dist/cli/server-ctl.js +2 -2
- package/dist/cli/server.js +9 -4
- package/dist/cli/server.js.map +1 -1
- package/dist/daemon/restart-intent.js +7 -2
- package/dist/daemon/restart-intent.js.map +1 -1
- package/dist/dispatcher-service/dispatcher/base-prompt.js +4 -4
- package/dist/dispatcher-service/dispatcher/base-prompt.js.map +1 -1
- package/dist/dispatcher-service/dispatcher/service.js +7 -10
- package/dist/dispatcher-service/dispatcher/service.js.map +1 -1
- package/dist/dispatcher-service/dispatcher-workspace.js +108 -0
- package/dist/dispatcher-service/dispatcher-workspace.js.map +1 -0
- package/dist/dispatcher-service/service.js +5 -12
- package/dist/dispatcher-service/service.js.map +1 -1
- package/dist/dispatcher-service/team/service.js +214 -65
- package/dist/dispatcher-service/team/service.js.map +1 -1
- package/dist/dispatcher-service/team/store.js +6 -0
- package/dist/dispatcher-service/team/store.js.map +1 -1
- package/dist/dispatcher-service/team/types.js.map +1 -1
- package/dist/dispatcher-service/teammate/identity-store.js +11 -77
- package/dist/dispatcher-service/teammate/identity-store.js.map +1 -1
- package/dist/dispatcher-service/teammate/name-allocator.js +86 -0
- package/dist/dispatcher-service/teammate/name-allocator.js.map +1 -0
- package/dist/dispatcher-service/teammate/runtime-state.js +26 -8
- package/dist/dispatcher-service/teammate/runtime-state.js.map +1 -1
- package/dist/dispatcher-service/teammate/service.js +365 -113
- package/dist/dispatcher-service/teammate/service.js.map +1 -1
- package/dist/dispatcher-service/teammate/session-ledger.js +306 -0
- package/dist/dispatcher-service/teammate/session-ledger.js.map +1 -0
- package/dist/dispatcher-service/teammate/types.js +14 -0
- package/dist/dispatcher-service/teammate/types.js.map +1 -1
- package/dist/dispatcher-service/teammate/worktree-manager.js +74 -4
- package/dist/dispatcher-service/teammate/worktree-manager.js.map +1 -1
- package/dist/dispatcher-service/teammate/worktree-paths.js +53 -0
- package/dist/dispatcher-service/teammate/worktree-paths.js.map +1 -0
- package/dist/mcp/team-mcp.js +86 -75
- package/dist/mcp/team-mcp.js.map +1 -1
- package/dist/mcp/teammate-mcp.js +32 -25
- package/dist/mcp/teammate-mcp.js.map +1 -1
- package/dist/onboard/run.js +3 -3
- package/dist/onboard/run.js.map +1 -1
- package/dist/onboard/uninstall.js +7 -1
- package/dist/onboard/uninstall.js.map +1 -1
- package/dist/platform/logs.js +32 -0
- package/dist/platform/logs.js.map +1 -0
- package/dist/platform/owner-only-dir.js +37 -0
- package/dist/platform/owner-only-dir.js.map +1 -0
- package/dist/platform/paths.js +130 -33
- package/dist/platform/paths.js.map +1 -1
- package/dist/platform/runtime-sockets.js +127 -0
- package/dist/platform/runtime-sockets.js.map +1 -0
- package/dist/server.js +47 -1
- package/dist/server.js.map +1 -1
- package/package.json +1 -1
- package/skills/dispatcher/SKILL.md +62 -38
- package/skills/dispatcher/references/dispatch-task.md +3 -3
- package/skills/dispatcher/references/inspect-and-resume.md +5 -3
- package/skills/dreamux-maintenance/SKILL.md +21 -0
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
import { Buffer } from 'node:buffer';
|
|
2
|
-
import { createHash } from 'node:crypto';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
3
|
import { BUILTIN_CLAUDE_CODE_PROVIDER_REF, } from '../../config/config.js';
|
|
4
4
|
import { teammateClaudeCodeStreamLogPath } from '../../agent-runtime/builtin/claude-code/paths.js';
|
|
5
5
|
import { teammateCodexAppServerErrorLogPath, teammateCodexAppServerLogPath, } from '../../agent-runtime/builtin/codex/paths.js';
|
|
6
|
-
import { dispatcherTeamMateRuntimeDir } from '../../platform/paths.js';
|
|
6
|
+
import { dispatcherCompletionSpillDir, dispatcherTeamMateRuntimeDir, } from '../../platform/paths.js';
|
|
7
7
|
import { validateDispatcherId } from '../../state/dispatcher-id.js';
|
|
8
|
+
import { ensureDispatcherWorkspace } from '../dispatcher-workspace.js';
|
|
8
9
|
import { TeamMateIdentityStore } from './identity-store.js';
|
|
9
10
|
import { TeamMateRuntimeStateStore } from './runtime-state.js';
|
|
11
|
+
import { TeamMateSessionLedger } from './session-ledger.js';
|
|
12
|
+
import { allocateConcreteName } from './name-allocator.js';
|
|
10
13
|
import { WorktreeManager } from './worktree-manager.js';
|
|
11
|
-
import { validateTeamMateName, dispatcherPrincipal, principalDispatcherId, } from './types.js';
|
|
14
|
+
import { requireLifecycleText, validateTeamMateName, dispatcherPrincipal, principalDispatcherId, } from './types.js';
|
|
12
15
|
const TURN_ORIGIN_CACHE_LIMIT = 256;
|
|
13
16
|
export class TeamMateAgentService {
|
|
14
17
|
opts;
|
|
15
18
|
identities;
|
|
19
|
+
sessionLedger;
|
|
16
20
|
worktrees = new WorktreeManager();
|
|
17
21
|
live = new Map();
|
|
18
22
|
submissionSeq = 0;
|
|
@@ -21,24 +25,69 @@ export class TeamMateAgentService {
|
|
|
21
25
|
this.identities = new TeamMateIdentityStore({
|
|
22
26
|
warn: (message, fields) => opts.log.warn(fields ?? {}, message),
|
|
23
27
|
});
|
|
28
|
+
this.sessionLedger = new TeamMateSessionLedger({
|
|
29
|
+
warn: (message, fields) => opts.log.warn(fields ?? {}, message),
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Read-only access to the durable session ledger (issue #182 PR-5). The
|
|
34
|
+
* public, filterable read surface is built on this in PR-6; exposed now so
|
|
35
|
+
* tests and future read tools can materialize recovery rows.
|
|
36
|
+
*/
|
|
37
|
+
sessions() {
|
|
38
|
+
return this.sessionLedger;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Allocate a concrete, never-reused TeamLeader name for a team (issue #188).
|
|
42
|
+
* The Team service calls this once at create time and persists the result as
|
|
43
|
+
* the team's durable `leader_name`; routing reads that stored name rather
|
|
44
|
+
* than reconstructing `${teamId}-leader`.
|
|
45
|
+
*/
|
|
46
|
+
async allocateLeaderName(dispatcherId, teamId) {
|
|
47
|
+
return this.allocateName(dispatcherId, 'team_leader', teamId, teamId);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Allocate a concrete name from an agent-supplied base slug (issue #188).
|
|
51
|
+
* Uniqueness is checked against ALL persisted identities (closed included),
|
|
52
|
+
* so a concrete name is never reused; the suffix is regenerated on collision
|
|
53
|
+
* and the allocation fails loudly if the attempt budget is exhausted.
|
|
54
|
+
*/
|
|
55
|
+
async allocateName(dispatcherId, role, base, teamSlug) {
|
|
56
|
+
const identities = await this.identities.list(dispatcherId);
|
|
57
|
+
const taken = new Set(identities.map((identity) => identity.name));
|
|
58
|
+
return allocateConcreteName({
|
|
59
|
+
role,
|
|
60
|
+
base,
|
|
61
|
+
...(teamSlug !== undefined ? { teamSlug } : {}),
|
|
62
|
+
exists: (candidate) => taken.has(candidate),
|
|
63
|
+
...(this.opts.suffixGenerator !== undefined
|
|
64
|
+
? { generateSuffix: this.opts.suffixGenerator }
|
|
65
|
+
: {}),
|
|
66
|
+
});
|
|
24
67
|
}
|
|
25
68
|
async spawn(input) {
|
|
26
69
|
return this.spawnScoped({
|
|
27
70
|
principal: dispatcherPrincipal(input.dispatcherId),
|
|
28
71
|
name: input.name,
|
|
29
72
|
prompt: input.prompt,
|
|
73
|
+
intent: input.intent,
|
|
30
74
|
...(input.agentRuntime !== undefined ? { agentRuntime: input.agentRuntime } : {}),
|
|
31
75
|
cwd: input.cwd,
|
|
32
76
|
...(input.worktree !== undefined ? { worktree: input.worktree } : {}),
|
|
33
|
-
...(input.intent !== undefined ? { intent: input.intent } : {}),
|
|
34
77
|
});
|
|
35
78
|
}
|
|
36
79
|
async spawnScoped(input) {
|
|
37
80
|
const dispatcherId = principalDispatcherId(input.principal);
|
|
38
|
-
const name = validateTeamMateName(input.name);
|
|
39
81
|
if (input.principal.kind === 'teammate') {
|
|
40
82
|
throw new Error('ordinary TeamMates cannot spawn TeamMates');
|
|
41
83
|
}
|
|
84
|
+
// The agent-supplied `name` is a base slug / display hint, not the final
|
|
85
|
+
// address (issue #188): require it non-empty, then allocate a concrete,
|
|
86
|
+
// never-reused name below and return it in the spawn result.
|
|
87
|
+
const displayName = requireLifecycleText(input.name, 'TeamMate spawn name');
|
|
88
|
+
// Required recovery subject — enforced here too for in-process callers that
|
|
89
|
+
// bypass the MCP shim / admin layer (issue #182 PR-3).
|
|
90
|
+
requireLifecycleText(input.intent, 'TeamMate spawn intent');
|
|
42
91
|
const cwd = input.sharedWorkspace?.sourceCwd ?? input.cwd;
|
|
43
92
|
if (input.principal.kind === 'team_leader' && input.sharedWorkspace === undefined) {
|
|
44
93
|
throw new Error('TeamLeader member spawn requires a shared team workspace');
|
|
@@ -46,64 +95,64 @@ export class TeamMateAgentService {
|
|
|
46
95
|
if (typeof cwd !== 'string' || cwd.trim() === '') {
|
|
47
96
|
throw new Error('TeamMate spawn requires cwd');
|
|
48
97
|
}
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
98
|
+
const owner = ownerForPrincipal(input.principal);
|
|
99
|
+
const role = input.principal.kind === 'team_leader' ? 'team_member' : 'teammate';
|
|
100
|
+
// Allocate the concrete address from the requested slug (Team members get
|
|
101
|
+
// the `tm-` rule). Checked against all persisted identities, never reused.
|
|
102
|
+
const name = await this.allocateName(dispatcherId, role, displayName);
|
|
53
103
|
const agentRuntimeId = input.agentRuntime ?? this.defaultAgentRuntime(dispatcherId);
|
|
54
104
|
const agent = this.resolveAgent(dispatcherId, agentRuntimeId);
|
|
55
105
|
const provider = this.opts.agentRuntimeProviders.resolve(agent.provider);
|
|
106
|
+
// Only a managed worktree is placed under the dispatcher workspace, so only
|
|
107
|
+
// managed mode resolves (and thus enforces) the dispatcher cwd contract;
|
|
108
|
+
// reuse-cwd never forces it (issue #182 PR-4).
|
|
109
|
+
const managedMode = (input.worktree?.mode ?? 'reuse-cwd') === 'managed';
|
|
56
110
|
const workspace = input.sharedWorkspace ??
|
|
57
111
|
(await this.worktrees.prepare({
|
|
58
112
|
dispatcherId,
|
|
59
113
|
teammateName: name,
|
|
60
114
|
cwd,
|
|
115
|
+
...(managedMode
|
|
116
|
+
? { dispatcherWorkspace: await this.dispatcherWorkspace(dispatcherId) }
|
|
117
|
+
: {}),
|
|
61
118
|
request: input.worktree,
|
|
62
119
|
}));
|
|
63
120
|
if (input.sharedWorkspace === undefined) {
|
|
64
121
|
await this.assertManagedWorktreeAvailable(dispatcherId, name, workspace.worktree);
|
|
65
122
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
sourceRepo: workspace.sourceRepo,
|
|
78
|
-
cwd: workspace.runtimeCwd,
|
|
79
|
-
runtimeCwd: workspace.runtimeCwd,
|
|
80
|
-
worktree: workspace.worktree,
|
|
81
|
-
intent: input.intent ?? null,
|
|
82
|
-
}));
|
|
83
|
-
this.assertPrincipalCanAccess(input.principal, identity);
|
|
84
|
-
identity = await this.identities.update(identity, {
|
|
123
|
+
// A spawn always starts a fresh runtime session, so it mints a new session
|
|
124
|
+
// id (issue #182 PR-5). The concrete name is fresh, so this is always a
|
|
125
|
+
// create — there is no closed-identity reuse path (issue #188).
|
|
126
|
+
const sessionId = randomUUID();
|
|
127
|
+
let identity = await this.identities.create({
|
|
128
|
+
dispatcherId,
|
|
129
|
+
name,
|
|
130
|
+
displayName,
|
|
131
|
+
owner,
|
|
132
|
+
role,
|
|
133
|
+
teamId: owner.kind === 'team' ? owner.team_id : null,
|
|
85
134
|
agentRuntime: agentRuntimeId,
|
|
135
|
+
sessionId,
|
|
86
136
|
sourceCwd: workspace.sourceCwd,
|
|
87
137
|
sourceRepo: workspace.sourceRepo,
|
|
88
138
|
cwd: workspace.runtimeCwd,
|
|
89
139
|
runtimeCwd: workspace.runtimeCwd,
|
|
90
140
|
worktree: workspace.worktree,
|
|
91
|
-
intent: input.intent
|
|
141
|
+
intent: input.intent,
|
|
92
142
|
status: 'starting',
|
|
93
|
-
closedAt: null,
|
|
94
|
-
closeNote: null,
|
|
95
|
-
lastError: null,
|
|
96
|
-
checkpoint: null,
|
|
97
143
|
});
|
|
144
|
+
this.assertPrincipalCanAccess(input.principal, identity);
|
|
98
145
|
const live = await this.startRuntime(dispatcherId, identity, provider, agent);
|
|
99
146
|
identity = live.state.current();
|
|
100
147
|
const turn = await this.submitPrompt(dispatcherId, name, input.prompt, {
|
|
101
148
|
principal: input.principal,
|
|
102
149
|
});
|
|
103
|
-
await this.
|
|
150
|
+
await this.sessionLedger.append({
|
|
151
|
+
identity: live.state.current(),
|
|
104
152
|
type: 'spawn',
|
|
105
153
|
prompt: input.prompt,
|
|
106
154
|
turnId: turn.turn_id ?? null,
|
|
155
|
+
turnOrigin: principalTurnOrigin(input.principal),
|
|
107
156
|
});
|
|
108
157
|
return { teammate: this.toStatus(live.state.current(), live.runtime), turn };
|
|
109
158
|
}
|
|
@@ -112,6 +161,7 @@ export class TeamMateAgentService {
|
|
|
112
161
|
principal: dispatcherPrincipal(input.dispatcherId),
|
|
113
162
|
name: input.name,
|
|
114
163
|
prompt: input.prompt,
|
|
164
|
+
...(input.intent !== undefined ? { intent: input.intent } : {}),
|
|
115
165
|
});
|
|
116
166
|
}
|
|
117
167
|
async sendScoped(input) {
|
|
@@ -119,19 +169,33 @@ export class TeamMateAgentService {
|
|
|
119
169
|
// not live — including one previously `close`d — is reopened from its
|
|
120
170
|
// persisted checkpoint and the turn is submitted, so send always works as
|
|
121
171
|
// long as the identity exists. reopenClosed scopes this revival to send;
|
|
122
|
-
// read-only verbs (last/
|
|
172
|
+
// read-only verbs (last/status) never silently reopen a closed teammate.
|
|
123
173
|
const dispatcherId = principalDispatcherId(input.principal);
|
|
124
174
|
const live = await this.ensureRuntime(dispatcherId, input.name, {
|
|
125
175
|
principal: input.principal,
|
|
126
176
|
reopenClosed: true,
|
|
127
177
|
});
|
|
178
|
+
// Optional intent update is applied BEFORE the turn so the recorded recovery
|
|
179
|
+
// subject reflects the work this turn is about (issue #182 PR-3). An empty
|
|
180
|
+
// string is ignored so a stray send never wipes a meaningful subject.
|
|
181
|
+
if (input.intent !== undefined && input.intent !== '') {
|
|
182
|
+
await live.state.updateIntent(input.intent);
|
|
183
|
+
}
|
|
128
184
|
const turn = await this.submitPrompt(dispatcherId, input.name, input.prompt, {
|
|
129
185
|
principal: input.principal,
|
|
130
186
|
});
|
|
131
|
-
|
|
187
|
+
// The send may have reopened a closed teammate from its checkpoint, so the
|
|
188
|
+
// session ledger continues the SAME session id carried on the identity
|
|
189
|
+
// (issue #182 PR-5); the optional intent update above is already reflected.
|
|
190
|
+
// A pre-PR-5 identity has no session id yet — mint one lazily so the event
|
|
191
|
+
// is captured rather than skipped (PR #187 review P3).
|
|
192
|
+
await live.state.ensureSessionId();
|
|
193
|
+
await this.sessionLedger.append({
|
|
194
|
+
identity: live.state.current(),
|
|
132
195
|
type: 'send',
|
|
133
196
|
prompt: input.prompt,
|
|
134
197
|
turnId: turn.turn_id ?? null,
|
|
198
|
+
turnOrigin: principalTurnOrigin(input.principal),
|
|
135
199
|
});
|
|
136
200
|
return { teammate: this.toStatus(live.state.current(), live.runtime), turn };
|
|
137
201
|
}
|
|
@@ -139,13 +203,17 @@ export class TeamMateAgentService {
|
|
|
139
203
|
return this.closeScoped({
|
|
140
204
|
principal: dispatcherPrincipal(input.dispatcherId),
|
|
141
205
|
name: input.name,
|
|
142
|
-
|
|
206
|
+
note: input.note,
|
|
143
207
|
});
|
|
144
208
|
}
|
|
145
209
|
async closeScoped(input) {
|
|
146
210
|
const dispatcherId = principalDispatcherId(input.principal);
|
|
147
211
|
const name = validateTeamMateName(input.name);
|
|
148
212
|
const identity = await this.mustIdentity(dispatcherId, name, input.principal);
|
|
213
|
+
// Required close reason — enforced for in-process callers too (issue #182
|
|
214
|
+
// PR-3); the Team dissolve path supplies an explicit note. Checked after the
|
|
215
|
+
// existence/access lookup so an inaccessible teammate reports that first.
|
|
216
|
+
requireLifecycleText(input.note, 'TeamMate close note');
|
|
149
217
|
const key = liveKey(dispatcherId, name);
|
|
150
218
|
const live = this.live.get(key);
|
|
151
219
|
if (live !== undefined) {
|
|
@@ -155,12 +223,17 @@ export class TeamMateAgentService {
|
|
|
155
223
|
const closed = await this.identities.update(identity, {
|
|
156
224
|
status: 'closed',
|
|
157
225
|
closedAt: Date.now(),
|
|
158
|
-
closeNote: input.note
|
|
226
|
+
closeNote: input.note,
|
|
159
227
|
worktree: await this.worktrees.cleanup(identity),
|
|
228
|
+
// A pre-PR-5 identity has no session id; mint a fresh, stable one in the
|
|
229
|
+
// same close write so the close event is captured rather than skipped
|
|
230
|
+
// (PR #187 review P3). Never re-keyed to the runtime thread id.
|
|
231
|
+
...(identity.session_id === null ? { sessionId: randomUUID() } : {}),
|
|
160
232
|
});
|
|
161
|
-
await this.
|
|
233
|
+
await this.sessionLedger.append({
|
|
234
|
+
identity: closed,
|
|
162
235
|
type: 'close',
|
|
163
|
-
note: input.note
|
|
236
|
+
note: input.note,
|
|
164
237
|
});
|
|
165
238
|
return { teammate: this.toStatus(closed, null) };
|
|
166
239
|
}
|
|
@@ -190,10 +263,28 @@ export class TeamMateAgentService {
|
|
|
190
263
|
}
|
|
191
264
|
async historyScoped(input) {
|
|
192
265
|
const dispatcherId = principalDispatcherId(input.principal);
|
|
266
|
+
// `history` is the durable session-ledger recovery surface (issue #188): the
|
|
267
|
+
// session ledger is the source of every recovery fact (prompts, assistant
|
|
268
|
+
// output, intent, turn count, last-seen). The per-name forward-only history
|
|
269
|
+
// index is no longer read here. We still enumerate one row per teammate
|
|
270
|
+
// identity — joining each to its session row — so the surface also covers a
|
|
271
|
+
// legacy/never-captured teammate that has no ledger session yet, and can
|
|
272
|
+
// surface live-only facts the ledger does not hold (runtime status, the
|
|
273
|
+
// resume checkpoint, worktree cleanup state).
|
|
193
274
|
const identities = await this.identities.list(dispatcherId);
|
|
275
|
+
const sessions = await this.sessionLedger.materializeSessions(dispatcherId);
|
|
276
|
+
// One session per teammate name (concrete names are never reused, so this is
|
|
277
|
+
// 1:1); if a name somehow carries more than one session id, keep the latest.
|
|
278
|
+
const sessionByName = new Map();
|
|
279
|
+
for (const session of sessions) {
|
|
280
|
+
const prev = sessionByName.get(session.name);
|
|
281
|
+
if (prev === undefined || session.last_seen_at >= prev.last_seen_at) {
|
|
282
|
+
sessionByName.set(session.name, session);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
194
285
|
const rows = [];
|
|
195
286
|
for (const identity of identities) {
|
|
196
|
-
const row =
|
|
287
|
+
const row = this.toLedgerRow(identity, sessionByName.get(identity.name) ?? null);
|
|
197
288
|
if (principalCanAccess(input.principal, identity) &&
|
|
198
289
|
this.matchesLedgerQuery(row, input)) {
|
|
199
290
|
rows.push(row);
|
|
@@ -211,46 +302,128 @@ export class TeamMateAgentService {
|
|
|
211
302
|
next_cursor: next < rows.length ? encodeCursor(next) : null,
|
|
212
303
|
};
|
|
213
304
|
}
|
|
214
|
-
async
|
|
215
|
-
return this.
|
|
305
|
+
async last(dispatcherId, name, turns) {
|
|
306
|
+
return this.lastScoped(dispatcherPrincipal(dispatcherId), name, turns);
|
|
216
307
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
}
|
|
230
|
-
async last(dispatcherId, name) {
|
|
231
|
-
return this.lastScoped(dispatcherPrincipal(dispatcherId), name);
|
|
232
|
-
}
|
|
233
|
-
async lastScoped(principal, name) {
|
|
308
|
+
/**
|
|
309
|
+
* Read a closed-or-live teammate's most recent settled turn(s) from the
|
|
310
|
+
* durable session ledger (issue #188). This is a pure read: it resolves the
|
|
311
|
+
* concrete name to exactly one identity/session and folds `sessions.jsonl`
|
|
312
|
+
* filtered by that session id — it NEVER starts, resumes, or requires a live
|
|
313
|
+
* runtime, so it works after a teammate is closed or stopped. `turns` defaults
|
|
314
|
+
* to 1 and is clamped-by-rejection to 1..5; the newest turn is `turns.at(-1)`.
|
|
315
|
+
* This is the failed-completion-delivery fallback, so it returns the assistant
|
|
316
|
+
* output as completely as it was durably captured (truncation is flagged).
|
|
317
|
+
*/
|
|
318
|
+
async lastScoped(principal, name, turns) {
|
|
319
|
+
const requestedTurns = validateLastTurns(turns);
|
|
234
320
|
const dispatcherId = principalDispatcherId(principal);
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
321
|
+
const identity = await this.mustIdentity(dispatcherId, validateTeamMateName(name), principal);
|
|
322
|
+
const teammate = this.toStatus(identity, this.live.get(liveKey(dispatcherId, identity.name))?.runtime ?? null);
|
|
323
|
+
const sessionId = identity.session_id;
|
|
324
|
+
if (sessionId === null) {
|
|
325
|
+
// A pre-#182-PR-5 identity that never settled under a session id has no
|
|
326
|
+
// durable turns to read; report an empty, well-formed result.
|
|
327
|
+
return {
|
|
328
|
+
teammate,
|
|
329
|
+
session_id: null,
|
|
330
|
+
requested_turns: requestedTurns,
|
|
331
|
+
returned_turns: 0,
|
|
332
|
+
turns: [],
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
// Fold the ledger by streaming it in file APPEND ORDER — the only correct
|
|
336
|
+
// turn ordering, since `event_id`/`timestamp` are both `Date.now()` (a wall
|
|
337
|
+
// clock that can collide within a millisecond or move backwards on an NTP
|
|
338
|
+
// step) and must NOT be used to order or pick the latest turn. The fold is
|
|
339
|
+
// BOUNDED: only the most recent `requestedTurns` settled turns retain their
|
|
340
|
+
// (possibly 160k-char) assistant text, so memory does not grow with session
|
|
341
|
+
// length. `firstSeq` records each turn's first-seen (submit) order so a turn
|
|
342
|
+
// is ranked by when it STARTED, not by when a (possibly duplicate) settle was
|
|
343
|
+
// written; it holds only short turn ids, never assistant text.
|
|
344
|
+
let nextSeq = 0;
|
|
345
|
+
const firstSeq = new Map();
|
|
346
|
+
const seqOf = (turnId) => {
|
|
347
|
+
const existing = firstSeq.get(turnId);
|
|
348
|
+
if (existing !== undefined)
|
|
349
|
+
return existing;
|
|
350
|
+
const seq = nextSeq;
|
|
351
|
+
nextSeq += 1;
|
|
352
|
+
firstSeq.set(turnId, seq);
|
|
353
|
+
return seq;
|
|
241
354
|
};
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
355
|
+
// Submit metadata (prompt/intent/origin) for turns not yet paired with a
|
|
356
|
+
// settle; dropped once paired, so it stays small.
|
|
357
|
+
const submitMeta = new Map();
|
|
358
|
+
// The bounded window of settled turns, keyed by turn id; size <= requestedTurns.
|
|
359
|
+
const recent = new Map();
|
|
360
|
+
for await (const event of this.sessionLedger.streamSession(dispatcherId, sessionId)) {
|
|
361
|
+
const turnId = event.turn_id;
|
|
362
|
+
if (turnId === null)
|
|
363
|
+
continue;
|
|
364
|
+
seqOf(turnId);
|
|
365
|
+
if (event.type === 'spawn' || event.type === 'send') {
|
|
366
|
+
submitMeta.set(turnId, {
|
|
367
|
+
turn_origin: event.turn_origin,
|
|
368
|
+
prompt_preview: event.prompt_preview,
|
|
369
|
+
intent: event.intent,
|
|
370
|
+
submitted_at: event.timestamp,
|
|
371
|
+
});
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
if (event.type !== 'settled')
|
|
375
|
+
continue;
|
|
376
|
+
const present = recent.get(turnId);
|
|
377
|
+
if (present !== undefined) {
|
|
378
|
+
// Duplicate/re-settle of a turn still in the window: override the settle
|
|
379
|
+
// fields in append order, keeping its already-paired submit fields.
|
|
380
|
+
present.settle_status = event.settle_status;
|
|
381
|
+
present.assistant = event.assistant;
|
|
382
|
+
present.assistant_preview = event.assistant_preview;
|
|
383
|
+
present.assistant_truncated = event.assistant_truncated;
|
|
384
|
+
present.settled_at = event.timestamp;
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
const submit = submitMeta.get(turnId);
|
|
388
|
+
submitMeta.delete(turnId);
|
|
389
|
+
recent.set(turnId, {
|
|
390
|
+
turn_id: turnId,
|
|
391
|
+
turn_origin: submit?.turn_origin ?? null,
|
|
392
|
+
prompt_preview: submit?.prompt_preview ?? null,
|
|
393
|
+
intent: submit?.intent ?? null,
|
|
394
|
+
submitted_at: submit?.submitted_at ?? null,
|
|
395
|
+
settled_at: event.timestamp,
|
|
396
|
+
settle_status: event.settle_status,
|
|
397
|
+
assistant: event.assistant,
|
|
398
|
+
assistant_preview: event.assistant_preview,
|
|
399
|
+
assistant_truncated: event.assistant_truncated,
|
|
400
|
+
});
|
|
401
|
+
if (recent.size > requestedTurns) {
|
|
402
|
+
// Evict the oldest-by-first-seen turn so the window holds the most recent
|
|
403
|
+
// `requestedTurns` turns by START order (a late re-settle of an already
|
|
404
|
+
// evicted, older turn is evicted again here rather than resurfacing).
|
|
405
|
+
let evictId;
|
|
406
|
+
let evictSeq = Infinity;
|
|
407
|
+
for (const id of recent.keys()) {
|
|
408
|
+
const seq = firstSeq.get(id) ?? Infinity;
|
|
409
|
+
if (seq < evictSeq) {
|
|
410
|
+
evictSeq = seq;
|
|
411
|
+
evictId = id;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
if (evictId !== undefined)
|
|
415
|
+
recent.delete(evictId);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
// `last` is the completion fallback, so it returns SETTLED turns (those with
|
|
419
|
+
// a durable assistant output), ordered oldest-first by start order.
|
|
420
|
+
const lastTurns = [...recent.values()].sort((a, b) => (firstSeq.get(a.turn_id) ?? 0) - (firstSeq.get(b.turn_id) ?? 0));
|
|
251
421
|
return {
|
|
252
|
-
teammate
|
|
253
|
-
|
|
422
|
+
teammate,
|
|
423
|
+
session_id: sessionId,
|
|
424
|
+
requested_turns: requestedTurns,
|
|
425
|
+
returned_turns: lastTurns.length,
|
|
426
|
+
turns: lastTurns,
|
|
254
427
|
};
|
|
255
428
|
}
|
|
256
429
|
async channelInputScoped(principal, name, input) {
|
|
@@ -262,13 +435,31 @@ export class TeamMateAgentService {
|
|
|
262
435
|
const result = await live.runtime.channelInput(input);
|
|
263
436
|
if (result.status === 'submitted') {
|
|
264
437
|
recordTurnOrigin(live, result.turnId, 'channel');
|
|
438
|
+
// Capture the channel-origin turn in the durable session ledger (issue
|
|
439
|
+
// #182 PR-5, PR #187 review P1): a TeamLeader's normal user turns arrive
|
|
440
|
+
// through a bound Team channel here, not via send, and would otherwise be
|
|
441
|
+
// missing from the session reconstruction. Mint a session id lazily for a
|
|
442
|
+
// pre-PR-5 identity. Best-effort: the ledger swallows its own write errors.
|
|
443
|
+
await live.state.ensureSessionId();
|
|
444
|
+
await this.sessionLedger.append({
|
|
445
|
+
identity: live.state.current(),
|
|
446
|
+
type: 'send',
|
|
447
|
+
prompt: input.text,
|
|
448
|
+
turnId: result.turnId,
|
|
449
|
+
turnOrigin: 'channel',
|
|
450
|
+
});
|
|
265
451
|
}
|
|
266
452
|
return result;
|
|
267
453
|
}
|
|
268
454
|
async createTeamLeader(input) {
|
|
269
455
|
const name = validateTeamMateName(input.name);
|
|
456
|
+
// #188: a concrete name is never reused — the duplicate check includes closed
|
|
457
|
+
// identities. The caller (TeamService) always passes a freshly allocated `tl-`
|
|
458
|
+
// name, so a pre-existing identity under this name (closed OR live) means a
|
|
459
|
+
// collision or a misuse of this seam; fail loud rather than rebinding the
|
|
460
|
+
// name to a new session (which would map one concrete name to >1 session).
|
|
270
461
|
const existing = await this.identities.get(input.dispatcherId, name);
|
|
271
|
-
if (existing !== null
|
|
462
|
+
if (existing !== null) {
|
|
272
463
|
throw new Error(`TeamLeader ${JSON.stringify(name)} already exists`);
|
|
273
464
|
}
|
|
274
465
|
const agent = this.resolveAgent(input.dispatcherId, input.agentRuntime);
|
|
@@ -277,23 +468,18 @@ export class TeamMateAgentService {
|
|
|
277
468
|
kind: 'dispatcher',
|
|
278
469
|
dispatcher_id: input.dispatcherId,
|
|
279
470
|
};
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
cwd: input.runtimeCwd,
|
|
291
|
-
runtimeCwd: input.runtimeCwd,
|
|
292
|
-
worktree: input.worktree,
|
|
293
|
-
intent: input.intent ?? null,
|
|
294
|
-
}));
|
|
295
|
-
identity = await this.identities.update(identity, {
|
|
471
|
+
// A fresh TeamLeader session mints a new session id (issue #182 PR-5). The
|
|
472
|
+
// name is freshly allocated, so this is always a create — no reuse path.
|
|
473
|
+
const sessionId = randomUUID();
|
|
474
|
+
let identity = await this.identities.create({
|
|
475
|
+
dispatcherId: input.dispatcherId,
|
|
476
|
+
name,
|
|
477
|
+
displayName: input.displayName ?? null,
|
|
478
|
+
owner,
|
|
479
|
+
role: 'team_leader',
|
|
480
|
+
teamId: input.teamId,
|
|
296
481
|
agentRuntime: input.agentRuntime,
|
|
482
|
+
sessionId,
|
|
297
483
|
sourceCwd: input.sourceCwd,
|
|
298
484
|
sourceRepo: input.sourceRepo,
|
|
299
485
|
cwd: input.runtimeCwd,
|
|
@@ -301,18 +487,16 @@ export class TeamMateAgentService {
|
|
|
301
487
|
worktree: input.worktree,
|
|
302
488
|
intent: input.intent ?? null,
|
|
303
489
|
status: 'starting',
|
|
304
|
-
closedAt: null,
|
|
305
|
-
closeNote: null,
|
|
306
|
-
lastError: null,
|
|
307
|
-
checkpoint: null,
|
|
308
490
|
});
|
|
309
491
|
const live = await this.startRuntime(input.dispatcherId, identity, provider, agent);
|
|
310
492
|
identity = live.state.current();
|
|
311
493
|
const turn = await this.submitPrompt(input.dispatcherId, name, input.prompt);
|
|
312
|
-
await this.
|
|
494
|
+
await this.sessionLedger.append({
|
|
495
|
+
identity: live.state.current(),
|
|
313
496
|
type: 'spawn',
|
|
314
497
|
prompt: input.prompt,
|
|
315
498
|
turnId: turn.turn_id ?? null,
|
|
499
|
+
turnOrigin: 'dispatcher',
|
|
316
500
|
});
|
|
317
501
|
return { teammate: this.toStatus(live.state.current(), live.runtime), turn };
|
|
318
502
|
}
|
|
@@ -326,7 +510,6 @@ export class TeamMateAgentService {
|
|
|
326
510
|
'list',
|
|
327
511
|
'status',
|
|
328
512
|
'last',
|
|
329
|
-
'ctx',
|
|
330
513
|
'get_capabilities',
|
|
331
514
|
],
|
|
332
515
|
agent_runtimes: Object.entries(this.opts.config.agents).map(([agentRuntimeId, agent]) => this.agentRuntimeCapability(agentRuntimeId, agent)),
|
|
@@ -383,6 +566,7 @@ export class TeamMateAgentService {
|
|
|
383
566
|
dispatcherId: identity.dispatcher_id,
|
|
384
567
|
teammateName: identity.name,
|
|
385
568
|
cwd: identity.source_cwd,
|
|
569
|
+
dispatcherWorkspace: await this.dispatcherWorkspace(identity.dispatcher_id),
|
|
386
570
|
request: {
|
|
387
571
|
mode: 'managed',
|
|
388
572
|
...(identity.worktree.slug !== null ? { slug: identity.worktree.slug } : {}),
|
|
@@ -526,10 +710,32 @@ export class TeamMateAgentService {
|
|
|
526
710
|
status: settled.status,
|
|
527
711
|
result,
|
|
528
712
|
};
|
|
529
|
-
|
|
713
|
+
// Attempt reverse delivery first (unchanged timing), but isolate its
|
|
714
|
+
// failure so it never skips the durable settled-turn capture below — a
|
|
715
|
+
// failed delivery is exactly when the recovery metadata matters most
|
|
716
|
+
// (issue #182 PR-5, PR #187 review P2).
|
|
717
|
+
try {
|
|
718
|
+
await sink(dispatcherId, identity, envelope, turnOrigins.get(settled.turnId) ?? null);
|
|
719
|
+
}
|
|
720
|
+
catch (err) {
|
|
721
|
+
this.opts.log.warn({ dispatcher_id: dispatcherId, teammate: name, err: errInfo(err) }, 'teammate completion delivery failed');
|
|
722
|
+
}
|
|
723
|
+
// Capture the settled turn in the durable session ledger AFTER the
|
|
724
|
+
// delivery attempt — regardless of its outcome — so capture never perturbs
|
|
725
|
+
// reverse-delivery timing: final assistant output + the runtime
|
|
726
|
+
// checkpoint/session id, read from the freshest identity so a thread id set
|
|
727
|
+
// after spawn is recorded.
|
|
728
|
+
const settledIdentity = (await this.identities.get(dispatcherId, name).catch(() => null)) ?? identity;
|
|
729
|
+
await this.sessionLedger.append({
|
|
730
|
+
identity: settledIdentity,
|
|
731
|
+
type: 'settled',
|
|
732
|
+
turnId: settled.turnId,
|
|
733
|
+
assistant: result,
|
|
734
|
+
settleStatus: settled.status,
|
|
735
|
+
});
|
|
530
736
|
}
|
|
531
737
|
catch (err) {
|
|
532
|
-
this.opts.log.warn({ dispatcher_id: dispatcherId, teammate: name, err: errInfo(err) }, 'teammate
|
|
738
|
+
this.opts.log.warn({ dispatcher_id: dispatcherId, teammate: name, err: errInfo(err) }, 'teammate settled-turn capture failed');
|
|
533
739
|
}
|
|
534
740
|
}
|
|
535
741
|
async mustIdentity(dispatcherId, name, principal = dispatcherPrincipal(dispatcherId)) {
|
|
@@ -563,26 +769,34 @@ export class TeamMateAgentService {
|
|
|
563
769
|
};
|
|
564
770
|
}
|
|
565
771
|
/**
|
|
566
|
-
* Per-teammate path context. The teammate runtime dir is the neutral root
|
|
567
|
-
*
|
|
568
|
-
*
|
|
569
|
-
*
|
|
772
|
+
* Per-teammate path context. The teammate runtime dir is the neutral root a
|
|
773
|
+
* runtime derives its state files from (Claude Code `mcp.json`; Codex keeps
|
|
774
|
+
* no per-teammate state files — its rendezvous socket is allocated per start
|
|
775
|
+
* under the private runtime-socket root, issue #182); only the central-tree
|
|
776
|
+
* log files vary by runtime, so the launcher selects them from the resolved
|
|
777
|
+
* provider ref.
|
|
570
778
|
*/
|
|
571
779
|
runtimePaths(identity, providerRef) {
|
|
572
780
|
const runtimeIdentity = runtimeIdentityName(identity);
|
|
573
781
|
const dispatcherDir = () => dispatcherTeamMateRuntimeDir(identity.dispatcher_id, runtimeIdentity);
|
|
782
|
+
// Completion spill belongs to the OPERATOR dispatcher's cache, not the
|
|
783
|
+
// teammate's composite runtime id, so it groups with the rest of that
|
|
784
|
+
// dispatcher's ephemera (issue #182 PR-2).
|
|
785
|
+
const completionSpillDir = () => dispatcherCompletionSpillDir(identity.dispatcher_id);
|
|
574
786
|
if (providerRef === BUILTIN_CLAUDE_CODE_PROVIDER_REF) {
|
|
575
787
|
const streamLog = () => teammateClaudeCodeStreamLogPath(identity.dispatcher_id, runtimeIdentity);
|
|
576
788
|
return {
|
|
577
789
|
dispatcherDir,
|
|
578
790
|
stdoutLogPath: streamLog,
|
|
579
791
|
stderrLogPath: streamLog,
|
|
792
|
+
completionSpillDir,
|
|
580
793
|
};
|
|
581
794
|
}
|
|
582
795
|
return {
|
|
583
796
|
dispatcherDir,
|
|
584
797
|
stdoutLogPath: () => teammateCodexAppServerLogPath(identity.dispatcher_id, runtimeIdentity),
|
|
585
798
|
stderrLogPath: () => teammateCodexAppServerErrorLogPath(identity.dispatcher_id, runtimeIdentity),
|
|
799
|
+
completionSpillDir,
|
|
586
800
|
};
|
|
587
801
|
}
|
|
588
802
|
/**
|
|
@@ -623,9 +837,21 @@ export class TeamMateAgentService {
|
|
|
623
837
|
return (this.opts.config.dispatchers.find((entry) => entry.id === dispatcherId) ??
|
|
624
838
|
null);
|
|
625
839
|
}
|
|
840
|
+
/**
|
|
841
|
+
* Resolve and validate the dispatcher workspace cwd (issue #182 PR-4): the
|
|
842
|
+
* root under which managed worktrees are placed. Fails loud when the
|
|
843
|
+
* dispatcher declares no explicit `cwd` — there is no state-dir fallback.
|
|
844
|
+
* Exposed so the Team service (which owns its own WorktreeManager) resolves
|
|
845
|
+
* the same workspace.
|
|
846
|
+
*/
|
|
847
|
+
async dispatcherWorkspace(dispatcherId) {
|
|
848
|
+
return ensureDispatcherWorkspace(this.opts.config, dispatcherId);
|
|
849
|
+
}
|
|
626
850
|
toStatus(identity, runtime) {
|
|
627
851
|
return {
|
|
628
852
|
name: identity.name,
|
|
853
|
+
display_name: identity.display_name,
|
|
854
|
+
session_id: identity.session_id,
|
|
629
855
|
role: identity.role,
|
|
630
856
|
team_id: identity.team_id,
|
|
631
857
|
owner: identity.owner,
|
|
@@ -644,14 +870,22 @@ export class TeamMateAgentService {
|
|
|
644
870
|
close_note: identity.close_note,
|
|
645
871
|
};
|
|
646
872
|
}
|
|
647
|
-
|
|
873
|
+
/**
|
|
874
|
+
* Build one recovery row for a teammate (issue #188). Historical recovery
|
|
875
|
+
* facts — last-seen, prompt/assistant previews, turn count — come from the
|
|
876
|
+
* durable SESSION LEDGER row (`session`), not the per-name history index. Live
|
|
877
|
+
* and identity-only facts (runtime status, resume checkpoint, worktree cleanup
|
|
878
|
+
* state, owner) come from the current identity. `session` is null for a
|
|
879
|
+
* legacy/never-captured teammate, which then shows identity facts only.
|
|
880
|
+
*/
|
|
881
|
+
toLedgerRow(identity, session) {
|
|
648
882
|
const runtime = this.live.get(liveKey(identity.dispatcher_id, identity.name))?.runtime ?? null;
|
|
649
|
-
const events = await this.identities.history(identity.dispatcher_id, identity.name);
|
|
650
|
-
const lastEvent = events.at(-1);
|
|
651
|
-
const lastPromptEvent = events.findLast((event) => event.prompt_preview !== null);
|
|
652
883
|
return {
|
|
653
884
|
id: identity.name,
|
|
654
885
|
name: identity.name,
|
|
886
|
+
display_name: identity.display_name,
|
|
887
|
+
session_id: identity.session_id,
|
|
888
|
+
turn_count: session?.turn_count ?? 0,
|
|
655
889
|
role: identity.role,
|
|
656
890
|
team_id: identity.team_id,
|
|
657
891
|
owner: identity.owner,
|
|
@@ -663,7 +897,7 @@ export class TeamMateAgentService {
|
|
|
663
897
|
worktree: identity.worktree,
|
|
664
898
|
created_at: identity.created_at,
|
|
665
899
|
updated_at: identity.updated_at,
|
|
666
|
-
last_seen_at:
|
|
900
|
+
last_seen_at: session?.last_seen_at ?? identity.updated_at,
|
|
667
901
|
state: identity.status,
|
|
668
902
|
status: identity.status,
|
|
669
903
|
runtime_status: runtime?.getStatus() ?? null,
|
|
@@ -673,8 +907,8 @@ export class TeamMateAgentService {
|
|
|
673
907
|
closed_at: identity.closed_at,
|
|
674
908
|
close_note: identity.close_note,
|
|
675
909
|
close_note_preview: identity.close_note !== null ? previewText(identity.close_note) : null,
|
|
676
|
-
last_prompt_preview:
|
|
677
|
-
last_assistant_preview: null,
|
|
910
|
+
last_prompt_preview: session?.last_prompt_preview ?? null,
|
|
911
|
+
last_assistant_preview: session?.last_assistant_preview ?? null,
|
|
678
912
|
cleanup_state: identity.worktree.cleanup_state,
|
|
679
913
|
resume: identity.closed_at === null || identity.checkpoint !== null
|
|
680
914
|
? { tool: 'send', name: identity.name, checkpoint: identity.checkpoint }
|
|
@@ -835,6 +1069,21 @@ function clampHistoryLimit(input) {
|
|
|
835
1069
|
}
|
|
836
1070
|
return Math.min(input, 100);
|
|
837
1071
|
}
|
|
1072
|
+
const LAST_TURNS_DEFAULT = 1;
|
|
1073
|
+
const LAST_TURNS_MAX = 5;
|
|
1074
|
+
/**
|
|
1075
|
+
* Validate the `last` turn count (issue #188): default 1, integer in 1..5.
|
|
1076
|
+
* Out-of-range is rejected (fail loud) rather than silently clamped, so a
|
|
1077
|
+
* caller asking for 10 turns learns its request was invalid.
|
|
1078
|
+
*/
|
|
1079
|
+
function validateLastTurns(input) {
|
|
1080
|
+
if (input === undefined)
|
|
1081
|
+
return LAST_TURNS_DEFAULT;
|
|
1082
|
+
if (!Number.isInteger(input) || input < 1 || input > LAST_TURNS_MAX) {
|
|
1083
|
+
throw new Error(`last turns must be an integer in 1..${LAST_TURNS_MAX}`);
|
|
1084
|
+
}
|
|
1085
|
+
return input;
|
|
1086
|
+
}
|
|
838
1087
|
function encodeCursor(offset) {
|
|
839
1088
|
return Buffer.from(JSON.stringify({ offset }), 'utf8').toString('base64url');
|
|
840
1089
|
}
|
|
@@ -859,6 +1108,9 @@ function ledgerRowMatchesText(row, grep) {
|
|
|
859
1108
|
return [
|
|
860
1109
|
row.id,
|
|
861
1110
|
row.name,
|
|
1111
|
+
row.display_name,
|
|
1112
|
+
row.session_id,
|
|
1113
|
+
row.team_id,
|
|
862
1114
|
row.agent_runtime,
|
|
863
1115
|
row.source_cwd,
|
|
864
1116
|
row.source_repo,
|