pi-dag-workflow 0.0.0-stage → 0.7.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 (108) hide show
  1. package/CHANGELOG.md +454 -0
  2. package/LICENSE +21 -0
  3. package/README.md +211 -2
  4. package/README.zh-CN.md +212 -0
  5. package/docs/COMMUNICATION.md +129 -0
  6. package/docs/RELEASING.md +66 -0
  7. package/package.json +70 -4
  8. package/src/agents/commands/child.ts +148 -0
  9. package/src/agents/commands/register.ts +404 -0
  10. package/src/agents/domain/context.ts +183 -0
  11. package/src/agents/domain/diagnostics.ts +32 -0
  12. package/src/agents/domain/owner.ts +448 -0
  13. package/src/agents/domain/profiles.ts +216 -0
  14. package/src/agents/domain/runtime.ts +603 -0
  15. package/src/agents/domain/schema.ts +15 -0
  16. package/src/agents/index.ts +5 -0
  17. package/src/agents/io/contracts.ts +50 -0
  18. package/src/agents/io/host.ts +16 -0
  19. package/src/agents/io/notices.ts +27 -0
  20. package/src/agents/io/persistence.ts +210 -0
  21. package/src/agents/io/process.ts +100 -0
  22. package/src/agents/io/profile-file.ts +16 -0
  23. package/src/agents/io/skills.ts +46 -0
  24. package/src/goal/commands/register.ts +235 -0
  25. package/src/goal/domain/failure.ts +14 -0
  26. package/src/goal/domain/owner.ts +575 -0
  27. package/src/goal/domain/policy.ts +8 -0
  28. package/src/goal/domain/prompt.ts +57 -0
  29. package/src/goal/domain/state.ts +176 -0
  30. package/src/goal/index.ts +5 -0
  31. package/src/goal/io/host.ts +8 -0
  32. package/src/plan/commands/register.ts +101 -0
  33. package/src/plan/domain/owner.ts +51 -0
  34. package/src/plan/domain/policy.ts +27 -0
  35. package/src/plan/domain/state.ts +15 -0
  36. package/src/plan/index.ts +13 -0
  37. package/src/plan/io/records.ts +3 -0
  38. package/src/retry/commands/menu.ts +158 -0
  39. package/src/retry/commands/register.ts +26 -0
  40. package/src/retry/domain/owner.ts +418 -0
  41. package/src/retry/domain/state.ts +71 -0
  42. package/src/retry/index.ts +5 -0
  43. package/src/retry/io/host.ts +15 -0
  44. package/src/shared/agent-view.ts +18 -0
  45. package/src/shared/completion.ts +111 -0
  46. package/src/shared/config-file.ts +106 -0
  47. package/src/shared/config.ts +112 -0
  48. package/src/shared/dag/cache.ts +26 -0
  49. package/src/shared/dag/graph.ts +170 -0
  50. package/src/shared/dag/layout.ts +130 -0
  51. package/src/shared/goal-view.ts +2 -0
  52. package/src/shared/i18n.ts +62 -0
  53. package/src/shared/message-types.ts +12 -0
  54. package/src/shared/messages.en.ts +1290 -0
  55. package/src/shared/passive-message.ts +50 -0
  56. package/src/shared/pi-cli.ts +29 -0
  57. package/src/shared/profile-launch.ts +110 -0
  58. package/src/shared/state-types.ts +5 -0
  59. package/src/shared/task-schema.ts +39 -0
  60. package/src/shared/task-types.ts +25 -0
  61. package/src/shared/terminal.ts +19 -0
  62. package/src/shared/tool-info.ts +8 -0
  63. package/src/shared/ui/filter.ts +144 -0
  64. package/src/shared/ui/menu.ts +447 -0
  65. package/src/shared/ui/panel.ts +768 -0
  66. package/src/shared/ui/presentation.ts +624 -0
  67. package/src/shared/ui/render-cache.ts +63 -0
  68. package/src/shared/ui/render.ts +641 -0
  69. package/src/shared/ui/symbols.ts +136 -0
  70. package/src/team/commands/bootstrap.ts +52 -0
  71. package/src/team/commands/controls.ts +91 -0
  72. package/src/team/commands/history.ts +100 -0
  73. package/src/team/commands/menu.ts +455 -0
  74. package/src/team/commands/register.ts +1321 -0
  75. package/src/team/domain/inbox.ts +178 -0
  76. package/src/team/domain/launcher.ts +355 -0
  77. package/src/team/domain/lifecycle.ts +34 -0
  78. package/src/team/domain/teams.ts +161 -0
  79. package/src/team/index.ts +4 -0
  80. package/src/team/io/inbox.ts +255 -0
  81. package/src/team/io/launcher.ts +270 -0
  82. package/src/team/io/lock.ts +19 -0
  83. package/src/team/io/registry.ts +477 -0
  84. package/src/team/io/runtime.ts +353 -0
  85. package/src/team/io/session-activity.ts +185 -0
  86. package/src/team/io/session-delete.ts +98 -0
  87. package/src/team/io/session-history.ts +153 -0
  88. package/src/team/io/session-tail.ts +132 -0
  89. package/src/team/io/sid-lock.ts +115 -0
  90. package/src/team/io/sid-session.ts +98 -0
  91. package/src/todos/commands/checkpoints.ts +31 -0
  92. package/src/todos/commands/register.ts +395 -0
  93. package/src/todos/domain/checkpoint-state.ts +32 -0
  94. package/src/todos/domain/checkpoint-summary.ts +146 -0
  95. package/src/todos/domain/cleanup.ts +25 -0
  96. package/src/todos/domain/owner.ts +109 -0
  97. package/src/todos/domain/presets.ts +192 -0
  98. package/src/todos/domain/state.ts +287 -0
  99. package/src/todos/index.ts +15 -0
  100. package/src/todos/io/records.ts +8 -0
  101. package/src/ui/index.ts +4 -0
  102. package/src/workflow/commands/connect.ts +81 -0
  103. package/src/workflow/commands/view.ts +121 -0
  104. package/src/workflow/domain/features.ts +5 -0
  105. package/src/workflow/domain/view-owner.ts +18 -0
  106. package/src/workflow/domain/view-state.ts +12 -0
  107. package/src/workflow/index.ts +225 -0
  108. package/src/workflow/io/session.ts +48 -0
@@ -0,0 +1,477 @@
1
+ import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { randomBytes } from 'node:crypto';
4
+ import { configPaths } from '../../shared/config.ts';
5
+ import { electLead, holderProcess, memberState, nextMemberName, RELEASED_PID, type AutoMessages, type MemberSettings, type RuntimeRecord, type TeamManifest, type TeamMember } from '../domain/teams.ts';
6
+ import { withLock } from './lock.ts';
7
+
8
+ /** Team data is its own directory shape: never reuse the machine-wide workflow config writer. */
9
+ export const teamsRoot = () => join(configPaths().directory, 'teams');
10
+ const teamDir = (id: string) => join(teamsRoot(), id);
11
+ const manifestPath = (id: string) => join(teamDir(id), 'team.json');
12
+ const manifestLock = (id: string) => join(teamDir(id), 'team.lock');
13
+ /**
14
+ * One short-lived lock per team for member-name allocation. Two sessions joining or renaming at the same time
15
+ * would otherwise each pick the same free name under their own member lock. Lock order: name, then member.
16
+ */
17
+ const nameLock = (id: string) => join(teamDir(id), 'names.lock');
18
+ const membersDir = (id: string) => join(teamDir(id), 'members');
19
+ const memberPath = (id: string, sessionId: string) => join(membersDir(id), `${sessionId}.json`);
20
+ /** Baseline epoch keeps timestamps monotonic and readable without Date in pure callers. */
21
+ export const now = () => Date.now();
22
+
23
+ async function readJson<T>(path: string): Promise<T | undefined> {
24
+ try { return JSON.parse(await readFile(path, 'utf8')) as T; }
25
+ catch { return undefined; }
26
+ }
27
+ async function writeJson(path: string, value: unknown): Promise<void> {
28
+ await mkdir(join(path, '..'), { recursive: true });
29
+ const temporary = `${path}.${randomBytes(4).toString('hex')}.tmp`;
30
+ await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
31
+ await rename(temporary, path);
32
+ }
33
+
34
+ export function newTeamId(existing: readonly string[] = []): string {
35
+ for (;;) {
36
+ const id = `t-${randomBytes(2).toString('hex')}`;
37
+ if (!existing.includes(id)) return id;
38
+ }
39
+ }
40
+
41
+ export async function createTeam(input: { name: string; workspaceRoot: string }): Promise<TeamManifest> {
42
+ const existing = (await readdir(teamsRoot()).catch(() => [] as string[])).filter(name => name.startsWith('t-'));
43
+ const manifest: TeamManifest = { id: newTeamId(existing), name: input.name.trim(), workspaceRoot: input.workspaceRoot, nextId: 1 };
44
+ await writeJson(manifestPath(manifest.id), manifest);
45
+ await mkdir(membersDir(manifest.id), { recursive: true });
46
+ return manifest;
47
+ }
48
+
49
+ export async function readTeam(id: string): Promise<TeamManifest | undefined> { return readJson<TeamManifest>(manifestPath(id)); }
50
+
51
+ /**
52
+ * The only writer of team.json. Every field — name, goal, lead, nextId — goes through this lock, and
53
+ * the patch re-reads the current manifest inside it, so one field can never write back another
54
+ * field's old value. Lock order: the manifest lock is innermost; never wait on a process, a message
55
+ * or another team's files while holding it.
56
+ */
57
+ export async function updateTeam(
58
+ id: string,
59
+ patch: (team: TeamManifest) => TeamManifest | undefined | Promise<TeamManifest | undefined>,
60
+ ): Promise<TeamManifest | undefined> {
61
+ try {
62
+ return await withLock(manifestLock(id), async () => {
63
+ const team = await readTeam(id);
64
+ if (!team) return undefined;
65
+ const next = await patch(team);
66
+ // No write needed: report the manifest as it is now, read under the lock, not a pre-lock copy.
67
+ if (!next) return team;
68
+ await writeJson(manifestPath(id), next);
69
+ return next;
70
+ });
71
+ } catch (cause) {
72
+ // A team that does not exist has no directory to lock in. Report "no such team" instead of
73
+ // leaking the lock's ENOENT, and never create a directory just to take a lock in it.
74
+ if ((cause as NodeJS.ErrnoException).code === 'ENOENT' && !await readTeam(id)) return undefined;
75
+ throw cause;
76
+ }
77
+ }
78
+
79
+ export async function listTeams(workspaceRoot: string): Promise<TeamManifest[]> {
80
+ const ids = (await readdir(teamsRoot()).catch(() => [] as string[])).filter(name => name.startsWith('t-'));
81
+ const manifests = await Promise.all(ids.map(id => readTeam(id)));
82
+ return manifests.filter((team): team is TeamManifest => !!team && team.workspaceRoot === workspaceRoot).sort((a, b) => a.name.localeCompare(b.name));
83
+ }
84
+
85
+ /** Renaming only edits the manifest; the directory keeps its id. */
86
+ export async function renameTeam(id: string, name: string): Promise<TeamManifest | undefined> {
87
+ return updateTeam(id, team => ({ ...team, name: name.trim() }));
88
+ }
89
+
90
+ export async function readMembers(id: string): Promise<TeamMember[]> {
91
+ const files = (await readdir(membersDir(id)).catch(() => [] as string[])).filter(name => name.endsWith('.json'));
92
+ const members = await Promise.all(files.map(async file => {
93
+ const member = await readJson<TeamMember>(join(membersDir(id), file));
94
+ return member && typeof member.sessionId === 'string' && typeof member.name === 'string' ? member : undefined;
95
+ }));
96
+ return members.filter((member): member is TeamMember => !!member);
97
+ }
98
+
99
+ /**
100
+ * One member record by its stable session id: a single file read, so a watcher can re-read it cheaply
101
+ * instead of scanning the roster. A missing file is undefined, never a guess.
102
+ */
103
+ export async function readMember(id: string, sessionId: string): Promise<TeamMember | undefined> {
104
+ const member = await readJson<TeamMember>(memberPath(id, sessionId));
105
+ return member && typeof member.sessionId === 'string' && typeof member.name === 'string' ? member : undefined;
106
+ }
107
+
108
+ const memberLock = (id: string, sessionId: string) => join(membersDir(id), `${sessionId}.lock`);
109
+
110
+ /** What a caller last saw: a write only applies while the record still matches it. */
111
+ export interface MemberToken { pid: number; generation: number }
112
+
113
+ /**
114
+ * The only writer of a member record. Every field — holder, state, settings, runtime — goes through
115
+ * this per-member lock, and the patch re-reads the current record inside it, so an old process can
116
+ * never write its pre-takeover holder or generation back. Lock order: a member lock is innermost and
117
+ * never waits on a process, a message, the manifest, or another member.
118
+ */
119
+ async function updateMember(
120
+ id: string,
121
+ sessionId: string,
122
+ patch: (member: TeamMember | undefined) => TeamMember | undefined | Promise<TeamMember | undefined>,
123
+ ): Promise<TeamMember | undefined> {
124
+ // A dissolved team has no active manifest: its old processes may not keep writing member records.
125
+ if (!await readTeam(id)) return undefined;
126
+ try {
127
+ return await withLock(memberLock(id, sessionId), async () => {
128
+ const member = await readJson<TeamMember>(memberPath(id, sessionId));
129
+ const next = await patch(member);
130
+ // No write needed: report the record as it is now, read under the lock.
131
+ if (!next) return member;
132
+ await writeJson(memberPath(id, sessionId), next);
133
+ return next;
134
+ });
135
+ } catch (cause) {
136
+ if ((cause as NodeJS.ErrnoException).code === 'ENOENT' && !await readJson<TeamMember>(memberPath(id, sessionId))) return undefined;
137
+ throw cause;
138
+ }
139
+ }
140
+
141
+ export async function joinTeam(id: string, input: { sessionId: string; sessionName?: string; pid: number; role?: string; now: number; alive?: (pid: number) => boolean }): Promise<TeamMember | undefined> {
142
+ const team = await readTeam(id);
143
+ if (!team) return undefined;
144
+ // The whole name allocation runs under the team name lock, so a concurrent join cannot take the same one.
145
+ let blocked: number | undefined;
146
+ let collision: string | undefined;
147
+ const member = await withLock(nameLock(id), () => updateMember(id, input.sessionId, async current => {
148
+ const holderPid = current ? holderProcess(current) : undefined;
149
+ // Without a probe a foreign holder counts as alive: an explicit --team never assumes "dead".
150
+ const live = holderPid !== undefined && (holderPid === input.pid || (input.alive ? input.alive(holderPid) : true));
151
+ // An explicit --team must not silently replace a live foreign holder: the same pid, a dead pid or
152
+ // a released record resumes; a live foreign one follows the existing anti-double-open rule.
153
+ if (current && holderPid !== input.pid && live) { blocked = holderPid; return undefined; }
154
+ // Naming needs the rest of the roster; reading member files waits on no lock.
155
+ const roster = (await readMembers(id)).filter(member => member.sessionId !== input.sessionId);
156
+ const explicit = input.sessionName?.trim();
157
+ // A resumed session keeps its own name; a new one must take the explicit name or an unused default. A
158
+ // collision is refused instead of silently becoming a different name.
159
+ if (!current && explicit && roster.some(member => member.name === explicit)) { collision = explicit; return undefined; }
160
+ const name = current?.name ?? (explicit || nextMemberName(roster));
161
+ return {
162
+ sessionId: input.sessionId,
163
+ name,
164
+ ...(input.role !== undefined ? { role: input.role } : current?.role !== undefined ? { role: current.role } : {}),
165
+ state: 'attached',
166
+ holder: { pid: input.pid, since: input.now },
167
+ retrying: current?.retrying ?? false,
168
+ generation: (current?.generation ?? 0) + 1,
169
+ autoMessages: current?.autoMessages ?? 'background',
170
+ ...(current?.runtime ? { runtime: current.runtime } : {}),
171
+ ...(current?.lastTurnAt !== undefined ? { lastTurnAt: current.lastTurnAt } : {}),
172
+ };
173
+ }));
174
+ if (collision !== undefined) throw new Error(`团队成员名 ${collision} 已被另一个会话使用:换一个名字,或在 /team 里先改名`);
175
+ if (blocked !== undefined) throw new Error(`该会话的成员记录仍被另一个运行中的进程(pid ${blocked})持有;先停下那个进程,或从 /team 发起接管`);
176
+ if (!member) return undefined;
177
+ // Default to this member as lead only if the manifest still has none, decided under its lock.
178
+ await updateTeam(id, current => current.leadMemberName ? undefined : { ...current, leadMemberName: member.name });
179
+ return member;
180
+ }
181
+
182
+ export async function assignLead(id: string, memberName: string): Promise<TeamManifest | undefined> {
183
+ const members = await readMembers(id);
184
+ const target = members.find(m => m.name === memberName);
185
+ if (!target) return undefined;
186
+ return updateTeam(id, team => ({ ...team, leadMemberName: target.name }));
187
+ }
188
+
189
+ /** The real boundaries a member reports. There is no heartbeat: only these events carry a time. */
190
+ export type MemberActivity = 'turn_start' | 'tool_call' | 'turn_end' | 'settled';
191
+
192
+ /**
193
+ * A member event writes what actually happened: the pid-derived state, the Retry flag, and — for a real
194
+ * boundary — the activity time and the busy fact. No heartbeat is written and no age threshold exists:
195
+ * silence is simply "no event since the last one". Everything here goes through the holder fence, so an
196
+ * event that started before a takeover can neither write the old pid nor the old generation back, and it
197
+ * cannot leave a late timestamp on the new representative.
198
+ */
199
+ export async function noteMemberEvent(
200
+ id: string,
201
+ sessionId: string,
202
+ input: { alive?: boolean; retrying?: boolean; background?: boolean; ownerPid?: number; activity?: MemberActivity; at?: number; sessionFile?: string | null; model?: string; thinking?: string; sessionName?: string; instanceId?: string; bootstrap?: TeamMember['bootstrap'] } = {},
203
+ ): Promise<{ status: 'written' | 'lost' | 'missing'; member?: TeamMember; holderPid?: number }> {
204
+ // The holder check runs inside the member lock: an event that started before a takeover must not
205
+ // write the old pid or an older generation back over the new holder.
206
+ const member = await updateMember(id, sessionId, current => {
207
+ if (!current) return undefined;
208
+ if (input.ownerPid !== undefined && current.holder.pid !== input.ownerPid) return undefined;
209
+ const background = input.background ?? current.state === 'headless';
210
+ const at = input.at ?? Date.now();
211
+ const activity = input.activity;
212
+ return {
213
+ ...current,
214
+ state: memberState(current, input.alive, background),
215
+ ...(input.retrying === undefined ? {} : { retrying: input.retrying }),
216
+ // A real boundary is the only thing that moves activity: turn start and tool calls mean "inside a
217
+ // run", and only a real settlement means "idle again" (turn_end is not full settlement).
218
+ ...(activity === undefined ? {} : { lastActivityAt: at, busy: activity === 'settled' ? false : activity === 'turn_end' ? current.busy ?? true : true }),
219
+ ...(activity === 'turn_end' ? { lastTurnAt: at } : {}),
220
+ // The file fact moves only with a real boundary of the process that really holds the record.
221
+ ...(input.sessionFile === undefined ? {} : { sessionFile: input.sessionFile }),
222
+ ...(input.instanceId === undefined ? {} : { instanceId: input.instanceId }),
223
+ ...(input.model === undefined ? {} : { model: input.model }),
224
+ ...(input.thinking === undefined ? {} : { thinking: input.thinking }),
225
+ ...(input.sessionName === undefined ? {} : { sessionName: input.sessionName }),
226
+ ...(input.bootstrap === undefined ? {} : { bootstrap: input.bootstrap }),
227
+ };
228
+ });
229
+ if (!member) return { status: 'missing' };
230
+ if (input.ownerPid !== undefined && member.holder.pid !== input.ownerPid) return { status: 'lost', member, holderPid: member.holder.pid };
231
+ return { status: 'written', member };
232
+ }
233
+
234
+ /**
235
+ * A stopped process is gone: the record says `offline` outright. `since` is the stop time; no freshness
236
+ * stamp is written, because nothing reads one any more.
237
+ */
238
+ export async function markMemberOffline(id: string, sessionId: string, at: number, expected?: MemberToken): Promise<'written' | 'conflict' | 'missing'> {
239
+ let outcome: 'written' | 'conflict' | 'missing' = 'missing';
240
+ const member = await updateMember(id, sessionId, current => {
241
+ if (!current) return undefined;
242
+ if (expected && (current.holder.pid !== expected.pid || current.generation !== expected.generation)) { outcome = 'conflict'; return undefined; }
243
+ outcome = 'written';
244
+ return { ...current, state: 'offline', holder: { pid: current.holder.pid, since: at } };
245
+ });
246
+ return member ? outcome : 'missing';
247
+ }
248
+
249
+ /**
250
+ * A member whose record this process no longer represents: keep the record (its tasks and mail stay
251
+ * readable) but drop the holder, so it reads offline and no heartbeat can revive it.
252
+ */
253
+ export async function releaseMember(id: string, sessionId: string, at: number, expected?: MemberToken): Promise<'released' | 'conflict' | 'missing'> {
254
+ let outcome: 'released' | 'conflict' | 'missing' = 'missing';
255
+ const member = await updateMember(id, sessionId, current => {
256
+ if (!current) return undefined;
257
+ if (expected && (current.holder.pid !== expected.pid || current.generation !== expected.generation)) { outcome = 'conflict'; return undefined; }
258
+ outcome = 'released';
259
+ return { ...current, state: 'offline', holder: { pid: RELEASED_PID, since: at } };
260
+ });
261
+ return member ? outcome : 'missing';
262
+ }
263
+
264
+ /**
265
+ * The rollback of a cancelled switch restores only what this attempt changed: the holder and state it
266
+ * replaced, never the whole old object, and never a lower generation. A third party that took the
267
+ * record over in the meantime makes the token mismatch, so the rollback is skipped instead of
268
+ * reviving the previous representative.
269
+ */
270
+ export async function restoreMember(
271
+ id: string,
272
+ sessionId: string,
273
+ snapshot: Pick<TeamMember, 'holder' | 'state'>,
274
+ expected?: MemberToken,
275
+ ): Promise<'restored' | 'conflict' | 'missing'> {
276
+ let outcome: 'restored' | 'conflict' | 'missing' = 'missing';
277
+ const member = await updateMember(id, sessionId, current => {
278
+ if (!current) return undefined;
279
+ if (expected && (current.holder.pid !== expected.pid || current.generation !== expected.generation)) { outcome = 'conflict'; return undefined; }
280
+ outcome = 'restored';
281
+ return { ...current, holder: snapshot.holder, state: snapshot.state, generation: current.generation + 1 };
282
+ });
283
+ return member ? outcome : 'missing';
284
+ }
285
+
286
+ /** Takeover swaps the holder pid on the same session; a live holder needs an explicit force. */
287
+ export async function claimOwnership(id: string, sessionId: string, input: { pid: number; now: number; force?: boolean; probe?: (pid: number) => boolean }): Promise<{ status: 'direct' | 'blocked' | 'forced' | 'missing'; member?: TeamMember; previous?: TeamMember }> {
288
+ // Decide, bump and write under one lock: two claimants from the same old generation cannot both
289
+ // report a successful takeover, and the liveness question is asked about the holder this lock sees
290
+ // — never about a record read before it.
291
+ let status: 'direct' | 'blocked' | 'forced' | 'missing' = 'missing';
292
+ let previous: TeamMember | undefined;
293
+ const member = await updateMember(id, sessionId, current => {
294
+ if (!current) return undefined;
295
+ const holderPid = holderProcess(current);
296
+ // A released or corrupted record has no process; our own pid is a resume. Any other holder is
297
+ // asked of the probe, and without one it is conservatively treated as alive.
298
+ const foreign = holderPid !== undefined && holderPid !== input.pid;
299
+ const alive = foreign ? (input.probe ? input.probe(holderPid) : true) : false;
300
+ if (alive && !input.force) { status = 'blocked'; return undefined; }
301
+ status = alive ? 'forced' : 'direct';
302
+ // The record this claim replaces: a rollback restores that, never a snapshot read before the lock.
303
+ previous = { ...current };
304
+ return { ...current, holder: { pid: input.pid, since: input.now }, generation: current.generation + 1 };
305
+ });
306
+ if (!member) return { status: 'missing' };
307
+ return { status, member, ...(previous ? { previous } : {}) };
308
+ }
309
+
310
+ /** Panel ticket owns the three-state switch; this is its write path. */
311
+ /** Remember a launched member's runtime files so a panel stop can find and clean them up. */
312
+ export async function setMemberRuntime(id: string, sessionId: string, runtime: RuntimeRecord): Promise<TeamMember | undefined> {
313
+ return updateMember(id, sessionId, current => current ? { ...current, runtime } : undefined);
314
+ }
315
+
316
+ /**
317
+ * Attach a launch's runtime files to the member record that launch produced — but only while that record
318
+ * is still held by the PID the launch really started. The check runs inside the member lock, so a record
319
+ * another process took over in the meantime is left alone: nobody writes someone else's channel.
320
+ */
321
+ export async function bindMemberRuntime(id: string, sessionId: string, runtime: RuntimeRecord, expectedPid: number): Promise<'bound' | 'conflict' | 'missing'> {
322
+ let outcome: 'bound' | 'conflict' = 'conflict';
323
+ const member = await updateMember(id, sessionId, current => {
324
+ if (!current) return undefined;
325
+ if (holderProcess(current) !== expectedPid) { outcome = 'conflict'; return undefined; }
326
+ outcome = 'bound';
327
+ return { ...current, runtime };
328
+ });
329
+ return member ? outcome : 'missing';
330
+ }
331
+
332
+ /** Remove one member record; the caller decides whether the session file should stay. */
333
+ export type RemoveOutcome = 'removed' | 'conflict' | 'missing';
334
+
335
+ export async function removeMember(
336
+ id: string,
337
+ sessionId: string,
338
+ expected?: MemberToken,
339
+ guard?: { probe?: ((pid: number) => boolean) | undefined; selfPid?: number | undefined },
340
+ ): Promise<boolean | RemoveOutcome> {
341
+ try {
342
+ return await withLock(nameLock(id), () => withLock(memberLock(id, sessionId), async () => {
343
+ const member = await readJson<TeamMember>(memberPath(id, sessionId));
344
+ if (!member) return guard ? 'missing' as const : false;
345
+ if (expected && (member.holder.pid !== expected.pid || member.generation !== expected.generation)) return guard ? 'conflict' as const : false;
346
+ // The guard decides only when the caller states which process it is: then a record a *different*
347
+ // live process holds is left alone. Without that, the token check above is all we can know.
348
+ const holderPid = holderProcess(member);
349
+ const foreign = guard?.selfPid !== undefined && holderPid !== undefined && holderPid !== guard.selfPid;
350
+ // A foreign holder that cannot be probed is treated as alive: refusal is the safe answer.
351
+ if (foreign && (guard?.probe ? guard.probe(holderPid) : true)) return 'conflict' as const;
352
+ await rm(memberPath(id, sessionId), { force: true });
353
+ return guard ? 'removed' as const : true;
354
+ }));
355
+ } catch (cause) {
356
+ if ((cause as NodeJS.ErrnoException).code === 'ENOENT' && !await readJson<TeamMember>(memberPath(id, sessionId))) return guard ? 'missing' as const : false;
357
+ throw cause;
358
+ }
359
+ }
360
+
361
+ export async function setAutoMessages(id: string, sessionId: string, mode: AutoMessages): Promise<TeamMember | undefined> {
362
+ return updateMember(id, sessionId, current => current ? { ...current, autoMessages: mode } : undefined);
363
+ }
364
+
365
+ /**
366
+ * Rename a member's display label. The session id is the identity; no other field changes.
367
+ */
368
+ export async function renameMember(id: string, sessionId: string, name: string, expected?: MemberToken & { instanceId?: string }): Promise<TeamMember | undefined> {
369
+ const clean = name.trim();
370
+ if (!clean) return undefined;
371
+ return withLock(nameLock(id), async () => {
372
+ let previous: string | undefined;
373
+ const renamed = await updateMember(id, sessionId, async current => {
374
+ if (!current || expected && (current.holder.pid !== expected.pid || current.generation !== expected.generation || expected.instanceId !== undefined && current.instanceId !== expected.instanceId)) return undefined;
375
+ previous = current.name;
376
+ const roster = await readMembers(id);
377
+ if (roster.some(member => member.sessionId !== sessionId && member.name === clean)) {
378
+ throw new Error(`团队成员名 ${clean} 已被另一个会话使用:换一个名字,或先在 /team 里改那个成员的名字`);
379
+ }
380
+ return { ...current, name: clean };
381
+ });
382
+ if (renamed?.name === clean && previous) await updateTeam(id, team => team.leadMemberName === previous ? { ...team, leadMemberName: clean } : undefined);
383
+ return renamed;
384
+ });
385
+ }
386
+
387
+ /** One pending patch per SID. New edits merge until applied; the holder token fences execution. */
388
+ export async function requestMemberControl(id: string, sessionId: string, fromId: string, patch: MemberSettings, expected: MemberToken & { instanceId?: string }): Promise<TeamMember | undefined> {
389
+ let queued = false;
390
+ const member = await updateMember(id, sessionId, current => {
391
+ if (!current || current.holder.pid !== expected.pid || current.generation !== expected.generation || current.instanceId !== expected.instanceId) return undefined;
392
+ queued = true;
393
+ return { ...current, control: {
394
+ id: randomBytes(12).toString('hex'), fromId, pid: current.holder.pid, generation: current.generation, ...(current.instanceId ? { instanceId: current.instanceId } : {}),
395
+ patch: { ...(current.control?.status === 'pending' ? current.control.patch : {}), ...patch }, status: 'pending',
396
+ } };
397
+ });
398
+ return queued ? member : undefined;
399
+ }
400
+ export async function finishMemberControl(id: string, sessionId: string, requestId: string, expected: MemberToken & { instanceId?: string }, error?: string): Promise<void> {
401
+ await updateMember(id, sessionId, current => current?.control?.id === requestId && current.holder.pid === expected.pid && current.generation === expected.generation && (expected.instanceId === undefined || current.instanceId === expected.instanceId)
402
+ ? { ...current, control: { ...current.control, status: error ? 'failed' : 'applied', ...(error ? { error } : {}) } } : undefined);
403
+ }
404
+
405
+ export async function leaveTeam(id: string, sessionId: string, alive?: (pid: number) => boolean, selfPid?: number): Promise<{ removed: boolean; dissolved: boolean; newLead?: string; conflict?: boolean }> {
406
+ const team = await readTeam(id);
407
+ if (!team) return { removed: false, dissolved: true };
408
+ const members = await readMembers(id);
409
+ const leaving = members.find(m => m.sessionId === sessionId);
410
+ // The record is removed under its own lock and only while it still matches this snapshot: a member
411
+ // that a live foreign process now holds keeps its record.
412
+ const outcome: RemoveOutcome = leaving
413
+ ? await removeMember(id, sessionId, { pid: leaving.holder.pid, generation: leaving.generation }, { probe: alive, selfPid }) as RemoveOutcome
414
+ : 'missing';
415
+ if (outcome === 'conflict') return { removed: false, dissolved: false, conflict: true };
416
+ const remaining = await readMembers(id);
417
+ // Whatever the lead becomes, it is reported from the manifest observed or written under the lock.
418
+ let observedLead = team.leadMemberName;
419
+ if (!remaining.length) {
420
+ const after = await updateTeam(id, current => {
421
+ // A concurrent manual assignment wins: only clear the lead that actually left.
422
+ if (current.leadMemberName === undefined || current.leadMemberName !== leaving?.name) return undefined;
423
+ const { leadMemberName: _lead, ...rest } = current;
424
+ return rest;
425
+ });
426
+ observedLead = after?.leadMemberName;
427
+ } else {
428
+ const elected = electLead(remaining, alive);
429
+ const after = await updateTeam(id, current => {
430
+ // A concurrent manual assignment wins: only replace the lead that actually left.
431
+ if (current.leadMemberName !== leaving?.name) return undefined;
432
+ const { leadMemberName: _lead, ...rest } = current;
433
+ return elected === undefined ? rest : { ...rest, leadMemberName: elected };
434
+ });
435
+ observedLead = after?.leadMemberName;
436
+ }
437
+ return { removed: true, dissolved: false, ...(observedLead !== undefined ? { newLead: observedLead } : {}) };
438
+ }
439
+
440
+ /** The inactive carrier a dissolved team keeps in its own directory: data never moves. */
441
+ export const archivedManifestPath = (id: string) => join(teamDir(id), 'team.archived.json');
442
+
443
+ /**
444
+ * Dissolving only de-registers the team: under the manifest lock the active `team.json` is renamed to
445
+ * `team.archived.json` in the same directory. Nothing moves — the member records, the old task files,
446
+ * the mailboxes and every runtime path keep their absolute location — so a panel action can never
447
+ * destroy or relocate what a member wrote, and the historic goal keeps its bytes in the old manifest.
448
+ * `readTeam`/`listTeams` only accept `team.json`, so the team is no longer active, and its `t-` directory
449
+ * still keeps `createTeam` from reusing the old id.
450
+ */
451
+ export async function dissolveTeam(id: string, options: { onlyIfEmpty?: boolean } = {}): Promise<boolean> {
452
+ try {
453
+ // Serialize with joinTeam: no member can arrive between the empty check and archival.
454
+ return await withLock(nameLock(id), () => withLock(manifestLock(id), async () => {
455
+ const team = await readTeam(id);
456
+ if (!team) return false;
457
+ if (options.onlyIfEmpty) {
458
+ const files = await readdir(membersDir(id)).catch((cause: NodeJS.ErrnoException) => {
459
+ if (cause.code === 'ENOENT') return [];
460
+ throw cause;
461
+ });
462
+ if (files.some(file => file.endsWith('.json'))) return false;
463
+ }
464
+ const target = archivedManifestPath(id);
465
+ // A second dissolution (a restored copy) must not overwrite the first carrier.
466
+ let path = target;
467
+ for (let suffix = 1; suffix < 1_000; suffix += 1) {
468
+ try { await stat(path); path = join(teamDir(id), `team.archived.${suffix}.json`); } catch { break; }
469
+ }
470
+ await rename(manifestPath(id), path);
471
+ return true;
472
+ }));
473
+ } catch (cause) {
474
+ if ((cause as NodeJS.ErrnoException).code === 'ENOENT' && !await readTeam(id)) return false;
475
+ throw cause;
476
+ }
477
+ }