@prjct.app/pi-team 0.6.1 → 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 (49) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/CONTRIBUTING.md +2 -1
  3. package/README.md +23 -178
  4. package/docs/architecture.md +36 -173
  5. package/package.json +10 -4
  6. package/src/commands/team-command.ts +37 -0
  7. package/src/domain/lease.ts +54 -0
  8. package/src/domain/member.ts +58 -0
  9. package/src/domain/message.ts +91 -0
  10. package/src/domain/request.ts +67 -0
  11. package/src/domain/team.ts +71 -0
  12. package/src/dynamic/domain.ts +110 -0
  13. package/src/dynamic/memory.ts +38 -0
  14. package/src/dynamic/panel.ts +155 -0
  15. package/src/dynamic/peer-log.ts +39 -0
  16. package/src/dynamic/runner.ts +196 -0
  17. package/src/dynamic/service.ts +292 -0
  18. package/src/dynamic/store.ts +57 -0
  19. package/src/dynamic/view.ts +21 -0
  20. package/src/dynamic/worker.ts +210 -0
  21. package/src/dynamic/workspace.ts +43 -0
  22. package/src/index.ts +204 -679
  23. package/src/process-identity.ts +68 -0
  24. package/src/runtime/delivery.ts +326 -0
  25. package/src/runtime/membership.ts +212 -0
  26. package/src/runtime/presence.ts +98 -0
  27. package/src/runtime/purge.ts +39 -0
  28. package/src/runtime/reconciler.ts +112 -0
  29. package/src/runtime/requests.ts +353 -0
  30. package/src/runtime/resources.ts +117 -0
  31. package/src/runtime/team-runtime.ts +47 -0
  32. package/src/runtime/team-tool.ts +191 -0
  33. package/src/storage/atomic.ts +347 -0
  34. package/src/storage/inbox-store.ts +290 -0
  35. package/src/storage/lease-store.ts +158 -0
  36. package/src/storage/paths.ts +76 -0
  37. package/src/storage/receipt-store.ts +117 -0
  38. package/src/storage/team-store.ts +190 -0
  39. package/src/supervisor/control-protocol.ts +125 -0
  40. package/src/supervisor/runtime-store.ts +231 -0
  41. package/src/supervisor/shutdown.ts +141 -0
  42. package/src/supervisor/supervisor.ts +657 -0
  43. package/src/supervisor/tmux-adapter.ts +192 -0
  44. package/src/supervisor/worker-bootstrap.ts +43 -0
  45. package/src/supervisor/worker-client.ts +233 -0
  46. package/src/ui/team-dashboard.ts +179 -0
  47. package/src/mailbox.ts +0 -536
  48. package/src/schema.ts +0 -25
  49. package/src/store.ts +0 -247
@@ -0,0 +1,210 @@
1
+ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
2
+ import { Type, type Static } from 'typebox';
3
+ import { Value } from 'typebox/value';
4
+ import { join } from 'node:path';
5
+ import { EntityIdSchema } from '../domain/team.ts';
6
+ import { TeamRuntime } from '../runtime/team-runtime.ts';
7
+ import { workerMembershipFromEnvironment } from '../runtime/membership.ts';
8
+ import { TeamPaths } from '../storage/paths.ts';
9
+ import { createWorkerBootstrap, type WorkerBootstrap } from '../supervisor/worker-bootstrap.ts';
10
+ import { DynamicStore } from './store.ts';
11
+ import { bounded, metadata, type Expert } from './domain.ts';
12
+ import { PEER_KINDS, recordPeerMessage } from './peer-log.ts';
13
+ import { StringEnum } from '@earendil-works/pi-ai';
14
+ import { ENGLISH_RULE, replyLines, replyProblems, replySchema, type Reply } from '@prjct.app/pi-tui-kit';
15
+
16
+ /**
17
+ * The stored result of an assignment: the reply rendered by code, so every
18
+ * Expert's result reads the same to the orchestrator. A malformed reply throws
19
+ * with what to fix, which reaches the Expert while it can still correct it.
20
+ */
21
+ export function expertReply(input: unknown): string {
22
+ const problems = replyProblems(input);
23
+ if (problems.length) throw new Error(`Not recorded. Fix and call team_reply again: ${problems.join('; ')}`);
24
+ const reply = input as Reply;
25
+ return metadata([`kind: ${reply.kind}`, ...replyLines(reply)].join('\n'));
26
+ }
27
+
28
+ const WorkerRequestSchema = Type.Object({
29
+ assignmentId: EntityIdSchema,
30
+ generation: Type.Integer({ minimum: 1 }),
31
+ ownerEpoch: Type.Integer({ minimum: 1 }),
32
+ task: Type.String({ maxLength: 8192 }),
33
+ /** Project memory for this Expert's stance, rendered by pi-memory in the orchestrator's process. */
34
+ memory: Type.Optional(Type.String({ maxLength: 4096 })),
35
+ }, { additionalProperties: false });
36
+ type WorkerRequest = Static<typeof WorkerRequestSchema>;
37
+
38
+ export function parseWorkerRequest(body: string): WorkerRequest {
39
+ bounded(body, 16 * 1024, 'Worker request');
40
+ const value: unknown = JSON.parse(body);
41
+ if (!Value.Check(WorkerRequestSchema, value)) throw new Error('Invalid Expert request body.');
42
+ bounded(value.task, 8192, 'Worker task');
43
+ if (value.memory !== undefined) bounded(value.memory, 4096, 'Worker memory');
44
+ return value;
45
+ }
46
+
47
+ /** Private supervised worker entry: no public Team commands or ambient orchestration. */
48
+ export function installExpertWorker(pi: ExtensionAPI): void {
49
+ pi.registerFlag('team-store', { description: 'Private supervised Expert storage root', type: 'string' });
50
+ const slot: { ctx?: ExtensionContext; runtime?: TeamRuntime; bootstrap?: WorkerBootstrap; expert?: Expert; store?: DynamicStore;
51
+ request?: string; timer?: ReturnType<typeof setInterval>; closed: boolean; serial: Promise<unknown> } = { closed: false, serial: Promise.resolve() };
52
+ const queue = <T>(action: () => Promise<T>): Promise<T> => {
53
+ const next = slot.serial.then(action); slot.serial = next.catch(() => {}); return next;
54
+ };
55
+ const membership = workerMembershipFromEnvironment();
56
+ if (!membership) throw new Error('Missing supervised Expert identity.');
57
+ const failClosed = (): void => { slot.closed = true; slot.ctx?.abort(); slot.ctx?.shutdown(); };
58
+ pi.registerTool({
59
+ name: 'team_reply', label: 'Expert reply',
60
+ description: 'Send the result of the current assignment as data, once. Kinds: change (files touched, what changed in each, checks run, what is pending), '
61
+ + 'answer (direct answer and file references), diagnosis (cause, evidence by file and line, fix status), '
62
+ + 'needs_input (a decision the orchestrator must make), blocked (why, and what you tried). '
63
+ + 'Code belongs in files; refer to it by path. Do not include secrets.',
64
+ parameters: replySchema(),
65
+ execute: async (_id, input, signal) => queue(async () => {
66
+ if (slot.closed || !slot.request || !slot.runtime) throw new Error('No active Expert assignment.');
67
+ const summary = expertReply(input);
68
+ const result = await slot.runtime.requests.reply(membership, slot.request, summary, signal);
69
+ if (!result.accepted) throw new Error('Assignment result rejected (terminal or fenced).');
70
+ slot.request = undefined;
71
+ slot.bootstrap?.ready();
72
+ return { content: [{ type: 'text', text: 'Result durably recorded.' }], details: {}, terminate: true };
73
+ }),
74
+ });
75
+ /** Other Experts of this Team, by role, with what each is doing now. */
76
+ const peers = async () => {
77
+ const state = slot.store ? await slot.store.read(membership.teamId) : undefined;
78
+ return (state?.experts ?? []).filter(e => e.id !== slot.expert?.id).map(e => {
79
+ const running = state?.assignments.find(a => a.expertId === e.id && a.status === 'running');
80
+ return { expert: e, running };
81
+ });
82
+ };
83
+ pi.registerTool({
84
+ name: 'team_peers', label: 'Teammates',
85
+ description: 'List the other Experts on this Team: role, status, and the task each is running now. Use a role with team_message.',
86
+ parameters: Type.Object({}, { additionalProperties: false }),
87
+ execute: async () => {
88
+ const list = await peers();
89
+ const text = list.length
90
+ ? list.map(({ expert, running }) => `${expert.role} [${expert.status}]${running ? ` · ${metadata(running.task, 160).split('\n')[0]}` : ''}`).join('\n')
91
+ : 'No other Experts on this Team yet.';
92
+ return { content: [{ type: 'text', text }], details: {} };
93
+ },
94
+ });
95
+ pi.registerTool({
96
+ name: 'team_message', label: 'Message a teammate',
97
+ description: 'Message another Expert directly, by role, without going through the orchestrator. Use blocker when their work blocks yours, question to ask, info to share a finding, handoff to pass them something. Answer a teammate the same way. ' + ENGLISH_RULE,
98
+ parameters: Type.Object({
99
+ to: Type.String({ minLength: 1, maxLength: 64, description: 'The teammate role (see team_peers).' }),
100
+ kind: StringEnum(PEER_KINDS),
101
+ body: Type.String({ minLength: 1, maxLength: 4000 }),
102
+ }, { additionalProperties: false }),
103
+ execute: async (_id, input, signal) => queue(async () => {
104
+ if (slot.closed || !slot.runtime || !slot.store || !slot.expert) throw new Error('This Expert is not ready to message yet.');
105
+ const target = (await peers()).find(({ expert }) => expert.role === input.to);
106
+ if (!target) throw new Error(`No teammate with role "${input.to}". Check team_peers.`);
107
+ const body = metadata(input.body, 4000);
108
+ try {
109
+ await slot.runtime.requests.send(membership, { to: `e-${target.expert.id}`, kind: input.kind, body: JSON.stringify({ fromRole: slot.expert.role, body }), signal });
110
+ } catch (error) {
111
+ if ((error as { code?: string }).code !== 'NOT_FOUND') throw error;
112
+ throw new Error(`${input.to} is not running right now, so it cannot receive messages. Put what you need in your team_reply; the orchestrator will route it.`);
113
+ }
114
+ await recordPeerMessage(slot.store.directory(membership.teamId), { at: new Date().toISOString(), from: slot.expert.role, to: input.to, kind: input.kind, body }).catch(() => {});
115
+ return { content: [{ type: 'text', text: `Sent to ${input.to}. Carry on; their answer will arrive as a message.` }], details: {} };
116
+ }),
117
+ });
118
+ const PEER_TOOLS = ['team_peers', 'team_message'];
119
+ pi.on('tool_call', event => {
120
+ if (PEER_TOOLS.includes(event.toolName) && !slot.closed) return undefined;
121
+ if (slot.closed || !slot.request || (event.toolName !== 'team_reply' && !slot.expert?.policy.tools.some(name => name === event.toolName))) {
122
+ return { block: true, reason: 'Outside the active Expert tool policy.' };
123
+ }
124
+ });
125
+ pi.on('before_agent_start', event => ({ systemPrompt: `${event.systemPrompt}\n\nYou are a persistent Team Expert, not the orchestrator.\nRole: ${slot.expert?.role}\n${slot.expert?.instructions ?? ''}\nBounded prior memory: ${slot.expert?.memory ?? ''}\nWork only on the current assignment. If another Expert's work blocks yours, or you need something from them, message them directly with team_message (see team_peers) instead of waiting for the orchestrator; answer teammates the same way. Finish with team_reply: pick the kind that matches what you did and fill its fields with evidence, never invented success. Code stays in files. Tool access is not an OS sandbox. Do not expose credentials.` }));
126
+ /**
127
+ * Messages from teammates arrive mid-work (steer) or open a turn when idle.
128
+ * They are claimed, read and finished once, like any durable message.
129
+ */
130
+ const deliverPeerMessages = async (ctx: ExtensionContext): Promise<void> => {
131
+ const runtime = slot.runtime;
132
+ if (!runtime) return;
133
+ const page = await runtime.inbox.listPending(membership.teamId, membership.memberId, 20);
134
+ for (const message of page.messages.filter(candidate => (PEER_KINDS as readonly string[]).includes(candidate.kind))) {
135
+ await runtime.delivery.claim(membership, message.messageId);
136
+ const read = await runtime.delivery.read(membership, message.messageId);
137
+ await runtime.delivery.finish(membership, message.messageId);
138
+ const parsed = (() => { try { return JSON.parse(read.body) as { fromRole?: string; body?: string }; } catch { return { body: read.body }; } })();
139
+ const from = metadata(parsed.fromRole ?? 'a teammate', 64);
140
+ pi.sendMessage({ customType: 'team-peer', display: true,
141
+ content: `Message from ${from} (${read.kind}, untrusted teammate data):\n${metadata(parsed.body ?? '', 4000)}\nIf it needs an answer, reply with team_message to "${from}".` },
142
+ { triggerTurn: true, deliverAs: ctx.isIdle() ? 'followUp' : 'steer' });
143
+ }
144
+ };
145
+ pi.on('session_start', async (_event, ctx) => {
146
+ slot.ctx = ctx;
147
+ const root = pi.getFlag('team-store');
148
+ if (typeof root !== 'string') { failClosed(); return; }
149
+ const store = new DynamicStore(root);
150
+ const state = await store.read(membership.teamId);
151
+ const expert = state?.experts.find(e => `e-${e.id}` === membership.alias && e.sessionRef === membership.sessionId);
152
+ if (!expert || ctx.sessionManager.getSessionId() !== expert.sessionRef || expert.status !== 'busy') { failClosed(); return; }
153
+ slot.expert = expert;
154
+ slot.store = store;
155
+ pi.setActiveTools([...expert.policy.tools, 'team_reply', ...PEER_TOOLS]);
156
+ const runtime = new TeamRuntime(new TeamPaths(join(root, 'transport')));
157
+ slot.runtime = runtime;
158
+ await runtime.memberships.assertOwner(membership);
159
+ const bootstrap = createWorkerBootstrap();
160
+ if (!bootstrap) { failClosed(); return; }
161
+ slot.bootstrap = bootstrap; bootstrap.attach(ctx); await bootstrap.start(); bootstrap.ready();
162
+ const renewed = { at: 0 };
163
+ const poll = async (): Promise<void> => {
164
+ if (slot.closed) return;
165
+ if (Date.now() - renewed.at >= 10_000) {
166
+ await runtime.memberships.heartbeat(membership);
167
+ if (slot.request) await runtime.delivery.renew(membership, slot.request);
168
+ renewed.at = Date.now();
169
+ }
170
+ await deliverPeerMessages(ctx);
171
+ if (slot.request) {
172
+ if (await runtime.requests.isCancelled(membership, slot.request)) { ctx.abort(); slot.request = undefined; bootstrap.ready(); }
173
+ return;
174
+ }
175
+ if (!ctx.isIdle()) return;
176
+ await runtime.delivery.deliverNextRequest(membership, true, async message => {
177
+ const input = parseWorkerRequest(message.body);
178
+ const current = await store.read(membership.teamId);
179
+ const assignment = current?.assignments.find(a => a.id === input.assignmentId && a.expertId === expert.id);
180
+ const sender = await runtime.teams.readMember(membership.teamId, message.fromMemberId);
181
+ if (!assignment || assignment.status !== 'running' || assignment.generation !== expert.generation ||
182
+ input.generation !== assignment.generation || input.ownerEpoch !== assignment.ownerEpoch || input.task !== assignment.task ||
183
+ current?.owner?.epoch !== assignment.ownerEpoch || current.owner.sessionId !== sender?.sessionId ||
184
+ sender?.alias !== 'orchestrator' || sender.state !== 'active' || sender.generation !== message.senderGeneration ||
185
+ current.runs.find(r => r.id === assignment.runId)?.status !== 'active') {
186
+ throw new Error('Fenced Expert assignment.');
187
+ }
188
+ slot.request = message.messageId; bootstrap.busy(message.messageId);
189
+ const remembered = input.memory?.trim() ? `\n\nWhat this project remembers:\n${input.memory.trim()}` : '';
190
+ pi.sendUserMessage(`Team assignment ${assignment.id}:\n${assignment.task}${remembered}\nReturn evidence using team_reply.`, { deliverAs: 'followUp' });
191
+ });
192
+ };
193
+ slot.timer = setInterval(() => { void queue(poll).catch(failClosed); }, 500);
194
+ slot.timer.unref();
195
+ });
196
+ pi.on('agent_end', async event => {
197
+ const last = event.messages.filter(m => m.role === 'assistant').at(-1);
198
+ if (last?.role === 'assistant' && ['error', 'aborted'].includes(last.stopReason) && slot.request && slot.runtime) {
199
+ await slot.runtime.delivery.fail(membership, slot.request).catch(() => {});
200
+ slot.request = undefined; slot.bootstrap?.ready();
201
+ }
202
+ });
203
+ pi.on('session_shutdown', async () => {
204
+ slot.closed = true;
205
+ if (slot.timer) clearInterval(slot.timer);
206
+ await slot.serial;
207
+ if (slot.request && slot.runtime) await slot.runtime.delivery.fail(membership, slot.request).catch(() => {});
208
+ slot.bootstrap?.dispose();
209
+ });
210
+ }
@@ -0,0 +1,43 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { lstat } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { ensurePrivateTree } from '../storage/atomic.ts';
5
+
6
+ export type Git = (cwd: string, args: readonly string[]) => Promise<string>;
7
+
8
+ const defaultGit: Git = (cwd, args) => new Promise((resolve, reject) => {
9
+ execFile('git', [...args], { cwd, env: process.env, timeout: 60_000 }, (error, stdout) => {
10
+ if (error) { reject(new Error(`git ${args[0]} failed`)); return; }
11
+ resolve(String(stdout).trim());
12
+ });
13
+ });
14
+
15
+ /** Whether Experts can work in parallel here: only inside a Git checkout, where each gets a worktree. */
16
+ export async function isGitCheckout(projectPath: string, git: Git = defaultGit): Promise<boolean> {
17
+ return git(projectPath, ['rev-parse', '--show-toplevel']).then(() => true, () => false);
18
+ }
19
+
20
+ /**
21
+ * A write-capable Expert's own Git worktree, created once and kept across its
22
+ * assignments. Parallel Experts then never share a checkout: each makes its
23
+ * own branch and pull request without moving another Expert's files or HEAD.
24
+ * It starts detached at the project's current HEAD; the Expert branches from
25
+ * there (or from origin) as its task says.
26
+ */
27
+ export async function expertWorkspace(input: {
28
+ readonly root: string; readonly teamId: string; readonly expertId: string; readonly projectPath: string;
29
+ }, git: Git = defaultGit): Promise<string> {
30
+ const path = join(input.root, 'projects', input.teamId, 'worktrees', input.expertId);
31
+ const existing = await lstat(path).catch(() => undefined);
32
+ if (existing) {
33
+ if (!existing.isDirectory() || existing.isSymbolicLink()) throw new Error('Unsafe Expert workspace.');
34
+ // Still a registered worktree of this project: reuse it as-is.
35
+ if (await git(path, ['rev-parse', '--is-inside-work-tree']).then(value => value === 'true', () => false)) return path;
36
+ throw new Error('Expert workspace exists but is not a Git worktree; remove it or run git worktree prune.');
37
+ }
38
+ await ensurePrivateTree(input.root, 'projects', input.teamId, 'worktrees');
39
+ const top = await git(input.projectPath, ['rev-parse', '--show-toplevel']);
40
+ await git(top, ['worktree', 'prune']);
41
+ await git(top, ['worktree', 'add', '--detach', path, 'HEAD']);
42
+ return path;
43
+ }