@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.
Files changed (88) hide show
  1. package/CHANGELOG.json +41 -0
  2. package/CHANGELOG.md +16 -1
  3. package/README.md +7 -4
  4. package/dist/admin/methods.js +86 -61
  5. package/dist/admin/methods.js.map +1 -1
  6. package/dist/admin/socket.js +31 -0
  7. package/dist/admin/socket.js.map +1 -1
  8. package/dist/agent-runtime/builtin/claude-code/runtime.js +8 -3
  9. package/dist/agent-runtime/builtin/claude-code/runtime.js.map +1 -1
  10. package/dist/agent-runtime/builtin/claude-code/supervisor.js +5 -0
  11. package/dist/agent-runtime/builtin/claude-code/supervisor.js.map +1 -1
  12. package/dist/agent-runtime/builtin/codex/codex-home.js +2 -3
  13. package/dist/agent-runtime/builtin/codex/codex-home.js.map +1 -1
  14. package/dist/agent-runtime/builtin/codex/paths.js +16 -62
  15. package/dist/agent-runtime/builtin/codex/paths.js.map +1 -1
  16. package/dist/agent-runtime/builtin/codex/runtime-support.js +4 -3
  17. package/dist/agent-runtime/builtin/codex/runtime-support.js.map +1 -1
  18. package/dist/agent-runtime/builtin/codex/runtime.js +5 -6
  19. package/dist/agent-runtime/builtin/codex/runtime.js.map +1 -1
  20. package/dist/agent-runtime/builtin/codex/supervisor.js +11 -1
  21. package/dist/agent-runtime/builtin/codex/supervisor.js.map +1 -1
  22. package/dist/agent-runtime/completion-body.js +14 -9
  23. package/dist/agent-runtime/completion-body.js.map +1 -1
  24. package/dist/channel/feishu/bot.js +0 -18
  25. package/dist/channel/feishu/bot.js.map +1 -1
  26. package/dist/channel/feishu/feishu-message.js +9 -2
  27. package/dist/channel/feishu/feishu-message.js.map +1 -1
  28. package/dist/cli/doctor.js +23 -0
  29. package/dist/cli/doctor.js.map +1 -1
  30. package/dist/cli/dreamux.js +1 -1
  31. package/dist/cli/dreamux.js.map +1 -1
  32. package/dist/cli/server-ctl.js +2 -2
  33. package/dist/cli/server.js +9 -4
  34. package/dist/cli/server.js.map +1 -1
  35. package/dist/daemon/restart-intent.js +7 -2
  36. package/dist/daemon/restart-intent.js.map +1 -1
  37. package/dist/dispatcher-service/dispatcher/base-prompt.js +4 -4
  38. package/dist/dispatcher-service/dispatcher/base-prompt.js.map +1 -1
  39. package/dist/dispatcher-service/dispatcher/service.js +7 -10
  40. package/dist/dispatcher-service/dispatcher/service.js.map +1 -1
  41. package/dist/dispatcher-service/dispatcher-workspace.js +108 -0
  42. package/dist/dispatcher-service/dispatcher-workspace.js.map +1 -0
  43. package/dist/dispatcher-service/service.js +5 -12
  44. package/dist/dispatcher-service/service.js.map +1 -1
  45. package/dist/dispatcher-service/team/service.js +214 -65
  46. package/dist/dispatcher-service/team/service.js.map +1 -1
  47. package/dist/dispatcher-service/team/store.js +6 -0
  48. package/dist/dispatcher-service/team/store.js.map +1 -1
  49. package/dist/dispatcher-service/team/types.js.map +1 -1
  50. package/dist/dispatcher-service/teammate/identity-store.js +11 -77
  51. package/dist/dispatcher-service/teammate/identity-store.js.map +1 -1
  52. package/dist/dispatcher-service/teammate/name-allocator.js +86 -0
  53. package/dist/dispatcher-service/teammate/name-allocator.js.map +1 -0
  54. package/dist/dispatcher-service/teammate/runtime-state.js +26 -8
  55. package/dist/dispatcher-service/teammate/runtime-state.js.map +1 -1
  56. package/dist/dispatcher-service/teammate/service.js +365 -113
  57. package/dist/dispatcher-service/teammate/service.js.map +1 -1
  58. package/dist/dispatcher-service/teammate/session-ledger.js +306 -0
  59. package/dist/dispatcher-service/teammate/session-ledger.js.map +1 -0
  60. package/dist/dispatcher-service/teammate/types.js +14 -0
  61. package/dist/dispatcher-service/teammate/types.js.map +1 -1
  62. package/dist/dispatcher-service/teammate/worktree-manager.js +74 -4
  63. package/dist/dispatcher-service/teammate/worktree-manager.js.map +1 -1
  64. package/dist/dispatcher-service/teammate/worktree-paths.js +53 -0
  65. package/dist/dispatcher-service/teammate/worktree-paths.js.map +1 -0
  66. package/dist/mcp/team-mcp.js +86 -75
  67. package/dist/mcp/team-mcp.js.map +1 -1
  68. package/dist/mcp/teammate-mcp.js +32 -25
  69. package/dist/mcp/teammate-mcp.js.map +1 -1
  70. package/dist/onboard/run.js +3 -3
  71. package/dist/onboard/run.js.map +1 -1
  72. package/dist/onboard/uninstall.js +7 -1
  73. package/dist/onboard/uninstall.js.map +1 -1
  74. package/dist/platform/logs.js +32 -0
  75. package/dist/platform/logs.js.map +1 -0
  76. package/dist/platform/owner-only-dir.js +37 -0
  77. package/dist/platform/owner-only-dir.js.map +1 -0
  78. package/dist/platform/paths.js +130 -33
  79. package/dist/platform/paths.js.map +1 -1
  80. package/dist/platform/runtime-sockets.js +127 -0
  81. package/dist/platform/runtime-sockets.js.map +1 -0
  82. package/dist/server.js +47 -1
  83. package/dist/server.js.map +1 -1
  84. package/package.json +1 -1
  85. package/skills/dispatcher/SKILL.md +62 -38
  86. package/skills/dispatcher/references/dispatch-task.md +3 -3
  87. package/skills/dispatcher/references/inspect-and-resume.md +5 -3
  88. 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 existing = await this.identities.get(dispatcherId, name);
50
- if (existing !== null && existing.status !== 'closed') {
51
- throw new Error(`TeamMate ${JSON.stringify(name)} already exists; use send`);
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
- const owner = ownerForPrincipal(input.principal);
67
- const role = input.principal.kind === 'team_leader' ? 'team_member' : 'teammate';
68
- let identity = existing ??
69
- (await this.identities.create({
70
- dispatcherId,
71
- name,
72
- owner,
73
- role,
74
- teamId: owner.kind === 'team' ? owner.team_id : null,
75
- agentRuntime: agentRuntimeId,
76
- sourceCwd: workspace.sourceCwd,
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 ?? null,
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.identities.appendHistory(live.state.current(), {
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/ctx/status) never silently reopen a closed teammate.
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
- await this.identities.appendHistory(live.state.current(), {
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
- ...(input.note !== undefined ? { note: input.note } : {}),
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 ?? null,
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.identities.appendHistory(closed, {
233
+ await this.sessionLedger.append({
234
+ identity: closed,
162
235
  type: 'close',
163
- note: input.note ?? null,
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 = await this.toLedgerRow(identity);
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 historyEvents(dispatcherId, name) {
215
- return this.historyEventsScoped(dispatcherPrincipal(dispatcherId), name);
305
+ async last(dispatcherId, name, turns) {
306
+ return this.lastScoped(dispatcherPrincipal(dispatcherId), name, turns);
216
307
  }
217
- async historyEventsScoped(principal, name) {
218
- const dispatcherId = principalDispatcherId(principal);
219
- const teammateName = validateTeamMateName(name);
220
- const identity = await this.identities.get(dispatcherId, teammateName);
221
- if (identity !== null)
222
- this.assertPrincipalCanAccess(principal, identity);
223
- return {
224
- teammate: identity === null
225
- ? null
226
- : this.toStatus(identity, this.live.get(liveKey(dispatcherId, teammateName))?.runtime ?? null),
227
- events: await this.identities.history(dispatcherId, teammateName),
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 live = await this.ensureRuntime(dispatcherId, name, {
236
- principal,
237
- });
238
- return {
239
- teammate: this.toStatus(live.state.current(), live.runtime),
240
- last: await live.runtime.getLast(),
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
- async context(dispatcherId, name) {
244
- return this.contextScoped(dispatcherPrincipal(dispatcherId), name);
245
- }
246
- async contextScoped(principal, name) {
247
- const dispatcherId = principalDispatcherId(principal);
248
- const live = await this.ensureRuntime(dispatcherId, name, {
249
- principal,
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: this.toStatus(live.state.current(), live.runtime),
253
- context: await live.runtime.getContext(),
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 && existing.status !== 'closed') {
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
- let identity = existing ??
281
- (await this.identities.create({
282
- dispatcherId: input.dispatcherId,
283
- name,
284
- owner,
285
- role: 'team_leader',
286
- teamId: input.teamId,
287
- agentRuntime: input.agentRuntime,
288
- sourceCwd: input.sourceCwd,
289
- sourceRepo: input.sourceRepo,
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.identities.appendHistory(live.state.current(), {
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
- await sink(dispatcherId, identity, envelope, turnOrigins.get(settled.turnId) ?? null);
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 completion delivery failed');
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 both
567
- * built-in runtimes derive their state files from (Codex `codex.sock`, Claude
568
- * Code `mcp.json`); only the central-tree log files vary by runtime, so the
569
- * launcher selects them from the resolved provider ref.
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
- async toLedgerRow(identity) {
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: lastEvent?.timestamp ?? identity.updated_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: lastPromptEvent?.prompt_preview ?? null,
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,