@aeondave/pi-persona 1.17.1 → 1.17.4

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 (146) hide show
  1. package/AGENTS.md +103 -64
  2. package/CHANGELOG.md +310 -1
  3. package/README.md +37 -23
  4. package/agents/architect.md +21 -0
  5. package/agents/balthasar.md +17 -22
  6. package/agents/casper.md +17 -23
  7. package/agents/evidence-verifier.md +31 -16
  8. package/agents/melchior.md +17 -22
  9. package/agents/operator.md +15 -55
  10. package/agents/research.md +14 -48
  11. package/agents/reviewer.md +20 -39
  12. package/agents/scout.md +14 -15
  13. package/agents/verifier.md +15 -16
  14. package/docs/ARCHITECTURE.md +280 -92
  15. package/docs/COLLABORATION.md +110 -0
  16. package/docs/EXPERIENCE.md +52 -17
  17. package/docs/MONITORS.md +62 -3
  18. package/docs/README.md +12 -0
  19. package/docs/REFERENCE.md +139 -38
  20. package/docs/SPINE.md +2 -2
  21. package/docs/STRATEGIES.md +23 -8
  22. package/docs/TELEMETRY.md +1 -0
  23. package/flows/deep-review.flow.json +1 -1
  24. package/package.json +15 -8
  25. package/personas/audit.md +28 -17
  26. package/personas/dev.md +30 -32
  27. package/personas/elite.md +223 -221
  28. package/personas/judge.md +25 -19
  29. package/personas/magi.md +26 -29
  30. package/personas/planner.md +31 -24
  31. package/personas/researcher.md +25 -26
  32. package/personas/swarm.md +35 -25
  33. package/personas/verify.md +30 -19
  34. package/src/agents/agent.ts +26 -0
  35. package/src/bridge.ts +28 -10
  36. package/src/bus/broker/client.ts +119 -21
  37. package/src/bus/broker/host.ts +123 -8
  38. package/src/bus/broker/messages.ts +31 -4
  39. package/src/bus/contact.ts +113 -38
  40. package/src/bus/peers.ts +15 -0
  41. package/src/bus/result-reader.ts +88 -0
  42. package/src/core/brief.ts +436 -353
  43. package/src/core/capabilities.ts +1 -1
  44. package/src/core/config.ts +15 -10
  45. package/src/core/leg-thinking.ts +57 -0
  46. package/src/core/model-catalog.ts +171 -0
  47. package/src/core/model-tier.ts +89 -0
  48. package/src/core/models.ts +13 -4
  49. package/src/core/monitor-output.ts +85 -0
  50. package/src/core/monitor.ts +34 -2
  51. package/src/core/nudge.ts +176 -23
  52. package/src/core/ownership.ts +1 -2
  53. package/src/core/pi-compat.ts +5 -12
  54. package/src/core/seed.ts +99 -6
  55. package/src/core/session-runs.ts +87 -0
  56. package/src/core/settings.ts +1 -1
  57. package/src/core/timer.ts +34 -9
  58. package/src/core/types.ts +1 -1
  59. package/src/engine/adapter.ts +95 -27
  60. package/src/engine/async.ts +213 -155
  61. package/src/engine/child.ts +12 -4
  62. package/src/engine/host-module.ts +12 -0
  63. package/src/engine/inproc.ts +99 -53
  64. package/src/engine/result-grant.ts +62 -0
  65. package/src/engine/session-tools.ts +6 -0
  66. package/src/engine/stream.ts +10 -20
  67. package/src/engine/worktree.ts +17 -7
  68. package/src/exocom/collaboration-delivery.ts +29 -0
  69. package/src/exocom/collaboration.ts +148 -0
  70. package/src/exocom/envelope.ts +43 -4
  71. package/src/exocom/gate.ts +7 -2
  72. package/src/exocom/install.ts +47 -18
  73. package/src/exocom/ledger.ts +58 -8
  74. package/src/exocom/plane.ts +32 -1
  75. package/src/exocom/profile.ts +40 -0
  76. package/src/exocom/registry.ts +5 -0
  77. package/src/exocom/status.ts +11 -1
  78. package/src/exocom/write-guard.ts +7 -0
  79. package/src/extension/broker-host.ts +61 -9
  80. package/src/extension/engine.ts +54 -28
  81. package/src/extension/hooks.ts +952 -868
  82. package/src/extension/identity.ts +41 -75
  83. package/src/extension/persona-model-settings.ts +31 -13
  84. package/src/extension/results.ts +136 -0
  85. package/src/extension/shared.ts +32 -7
  86. package/src/extension.ts +2965 -2611
  87. package/src/loader.ts +11 -3
  88. package/src/monitor/archive.ts +235 -0
  89. package/src/monitor/session.ts +21 -1
  90. package/src/orchestration/flow-journal.ts +33 -6
  91. package/src/orchestration/flow-run.ts +160 -32
  92. package/src/orchestration/flow.ts +20 -2
  93. package/src/orchestration/reducers.ts +9 -5
  94. package/src/orchestration/render.ts +1 -1
  95. package/src/orchestration/roster.ts +71 -23
  96. package/src/orchestration/sdk.ts +61 -11
  97. package/src/orchestration/strategies/compete.ts +5 -1
  98. package/src/orchestration/strategies/council-rounds.ts +2 -0
  99. package/src/orchestration/strategies/critic-loop.ts +3 -0
  100. package/src/orchestration/strategies/debate.ts +2 -0
  101. package/src/orchestration/strategies/fanout.ts +3 -2
  102. package/src/orchestration/strategies/judge.ts +5 -1
  103. package/src/orchestration/strategies/magi.ts +40 -23
  104. package/src/orchestration/strategies/map.ts +13 -3
  105. package/src/orchestration/strategies/pair.ts +2 -0
  106. package/src/orchestration/strategies/pipeline.ts +2 -0
  107. package/src/orchestration/strategies/synthesize.ts +27 -7
  108. package/src/orchestration/strategy.ts +31 -0
  109. package/src/orchestration/types.ts +10 -0
  110. package/src/persona/model-participants.ts +20 -7
  111. package/src/persona/orchestrate.ts +2 -0
  112. package/src/persona/persona.ts +8 -4
  113. package/src/results/archive.ts +281 -0
  114. package/src/results/record.ts +166 -0
  115. package/src/results/retrieval.ts +142 -0
  116. package/src/results/types.ts +128 -0
  117. package/src/telemetry/contract.ts +1 -1
  118. package/src/tools/background-run.ts +151 -0
  119. package/src/tools/council.ts +108 -66
  120. package/src/tools/delegate-tool.ts +349 -283
  121. package/src/tools/delegate.ts +35 -216
  122. package/src/tools/exocom-collaboration-input.ts +168 -0
  123. package/src/tools/exocom-collaboration.ts +315 -0
  124. package/src/tools/exocom-profile.ts +102 -0
  125. package/src/tools/exocom-work.ts +18 -53
  126. package/src/tools/exocom.ts +35 -22
  127. package/src/tools/flow.ts +103 -30
  128. package/src/tools/intercom-tool.ts +202 -128
  129. package/src/tools/intercom.ts +1 -1
  130. package/src/tools/models.ts +34 -9
  131. package/src/tools/monitor.ts +58 -6
  132. package/src/tools/read-result.ts +52 -0
  133. package/src/tools/timer.ts +34 -15
  134. package/src/ui/agent-overlay.ts +65 -16
  135. package/src/ui/agent-tree.ts +127 -19
  136. package/src/ui/compact-widget.ts +25 -0
  137. package/src/ui/failure-summary.ts +55 -0
  138. package/src/ui/literal-tool-result.ts +24 -0
  139. package/src/ui/model-picker.ts +158 -81
  140. package/src/ui/orchestration-launch.ts +49 -0
  141. package/src/ui/persona-settings.ts +9 -3
  142. package/src/ui/presentation.ts +24 -0
  143. package/src/ui/report-body.ts +63 -0
  144. package/src/ui/session-cards.ts +366 -0
  145. package/src/ui/wake-rows.ts +262 -0
  146. package/teams.yaml +5 -1
@@ -76,11 +76,14 @@ export async function worktreePreflight(root: string, exec: GitExec = defaultGit
76
76
  return { ok: true };
77
77
  }
78
78
 
79
- /** Capture the complete post-run worktree state as a portable unified diff. Tracked edits use
80
- * `git diff HEAD`; files newly created by a coding leg are added with `git diff --no-index`.
79
+ /** Capture the complete post-run worktree state as a portable unified diff from its pinned
80
+ * starting commit. Files newly created by a coding leg are added with `git diff --no-index`.
81
81
  * Exit code 1 is the normal "files differ" result for --no-index. */
82
- export async function captureWorktreeArtifact(root: string, exec: GitExec = defaultGitExec): Promise<WorktreeArtifact> {
83
- const tracked = await exec(["-C", root, "diff", "--no-ext-diff", "--binary", "HEAD", "--"]);
82
+ export async function captureWorktreeArtifact(root: string, baseCommit: string, exec: GitExec = defaultGitExec): Promise<WorktreeArtifact> {
83
+ if (!baseCommit.trim()) {
84
+ return { ok: false, error: "could not export the isolated worktree diff: starting commit is missing" };
85
+ }
86
+ const tracked = await exec(["-C", root, "diff", "--no-ext-diff", "--binary", baseCommit, "--"]);
84
87
  if (tracked.code !== 0) {
85
88
  return { ok: false, error: `could not export the isolated worktree diff: ${tracked.stderr.trim() || `git exited ${tracked.code}`}` };
86
89
  }
@@ -107,8 +110,9 @@ export async function captureWorktreeArtifact(root: string, exec: GitExec = defa
107
110
  return { ok: true, diff: parts.join("\n") };
108
111
  }
109
112
 
110
- /** Create a detached worktree off `root`'s HEAD, run `body(worktreeDir)`, then force-remove it. */
111
- export async function withWorktree<T>(root: string, exec: GitExec, body: (dir: string) => Promise<T>): Promise<T> {
113
+ /** Create a detached worktree off `root`'s HEAD, pin its starting commit, run
114
+ * `body(worktreeDir, baseCommit)`, then force-remove it. */
115
+ export async function withWorktree<T>(root: string, exec: GitExec, body: (dir: string, baseCommit: string) => Promise<T>): Promise<T> {
112
116
  const preflight = await worktreePreflight(root, exec);
113
117
  if (!preflight.ok) throw new Error(preflight.error);
114
118
  const dir = await mkdtemp(join(tmpdir(), "pi-persona-wt-"));
@@ -120,7 +124,13 @@ export async function withWorktree<T>(root: string, exec: GitExec, body: (dir: s
120
124
  throw new Error(`could not create git worktree: ${add.stderr.trim() || `git exited ${add.code}`}`);
121
125
  }
122
126
  try {
123
- return await body(dir);
127
+ const initial = await exec(["-C", dir, "rev-parse", "--verify", "HEAD^{commit}"]);
128
+ const baseCommit = initial.stdout.trim();
129
+ if (initial.code !== 0 || !baseCommit) {
130
+ const reason = initial.stderr.trim() || (initial.code !== 0 ? `git exited ${initial.code}` : "Git returned an empty commit id");
131
+ throw new Error(`could not resolve the initial worktree commit: ${reason}`);
132
+ }
133
+ return await body(dir, baseCommit);
124
134
  } finally {
125
135
  // Remove the worktree registration, then the dir (force: it may carry the agent's edits).
126
136
  // Never throw: cleanup must not replace the body's result (or its error) — `defaultGitExec`
@@ -0,0 +1,29 @@
1
+ /** Signed offer wakes project retained ledger data; the envelope is never its authority. */
2
+ import { attributePeer } from "../core/fence.ts";
3
+ import { collaborationOfferStatus } from "./collaboration.ts";
4
+ import { frameSigningPayload, type ExocomOffer, type ExocomSemanticFrame } from "./envelope.ts";
5
+ import type { LedgerState } from "./ledger.ts";
6
+
7
+ export function collaborationWriteIntent(frame: ExocomSemanticFrame, state: LedgerState): boolean {
8
+ if (frame.kind === "claim" || frame.kind === "task_create") return frame.write_set.length > 0;
9
+ return frame.kind === "task_take" && (state.collaboration?.tasks.find((task) => task.created.work_key === frame.work_key)?.created.write_set.length ?? 0) > 0;
10
+ }
11
+
12
+ export function deliverCollaborationOffer(
13
+ frame: ExocomOffer, state: LedgerState, sessionId: string, label: string, now: number,
14
+ ): { accepted: true; notice?: string } | { accepted: false; reason: string } {
15
+ if (frame.to_session !== sessionId) return { accepted: false, reason: "offer addressed to another session" };
16
+ const stored = state.collaboration?.offers.find((offer) => offer.offer_id === frame.offer_id);
17
+ if (!stored || frameSigningPayload(stored) !== frameSigningPayload(frame)) {
18
+ return { accepted: false, reason: "offer is not durable with this sender and payload" };
19
+ }
20
+ const tasks = state.collaboration?.tasks ?? [];
21
+ if (collaborationOfferStatus(stored, tasks, now) !== "open") return { accepted: true };
22
+ const task = tasks.find((candidate) => candidate.created.work_key === stored.work_key && candidate.created.msg_id === stored.task_id);
23
+ if (!task) return { accepted: true };
24
+ return { accepted: true, notice: [
25
+ `[pi-persona] Optional peer invitation · task=${stored.work_key} · offer=${stored.offer_id} · expires ${stored.expires_at}`,
26
+ "This creates no response obligation or permission. Inspect the task and take it voluntarily only within your authorized scope; otherwise continue your work.",
27
+ attributePeer(frame.from_session === task.created.from_session ? label : task.created.from_session, task.created.description),
28
+ ].join("\n") };
29
+ }
@@ -0,0 +1,148 @@
1
+ /** Voluntary peer work. Pure domain rules; claims and persistence stay in the ledger transaction. */
2
+ import { writeSetPathError } from "../core/ownership.ts";
3
+ import { isSemanticFrame, type ExocomSemanticFrame, type ExocomCollaborationFrame, type ExocomOffer, type ExocomProgress, type ExocomPublish, type ExocomTaskCreate, type ExocomTaskFinish, type ExocomTaskTake } from "./envelope.ts";
4
+
5
+ export interface CollaborationTask {
6
+ created: ExocomTaskCreate;
7
+ taken?: ExocomTaskTake;
8
+ progress?: ExocomProgress;
9
+ finished?: ExocomTaskFinish;
10
+ /** Loss of the owning registry lease is visible and never permits automatic pickup. */
11
+ stale?: true;
12
+ /** Receiver clock at the first lease-loss reconciliation, or release timestamp. */
13
+ staleAt?: number;
14
+ }
15
+ export interface CollaborationState { tasks: CollaborationTask[]; offers: ExocomOffer[]; evidence: ExocomPublish[]; }
16
+ export const COLLABORATION_LIMITS = { tasks: 64, offers: 64, evidence: 64, maxBytes: 131_072 } as const;
17
+ export function emptyCollaboration(): CollaborationState { return { tasks: [], offers: [], evidence: [] }; }
18
+ export function collaborationStatus(task: CollaborationTask): "open" | "taken" | "done" | "abandoned" | "stale" {
19
+ return task.finished?.outcome ?? (task.stale ? "stale" : task.taken ? "taken" : "open");
20
+ }
21
+ export function offerStatus(offer: ExocomOffer, now: number): "open" | "expired" {
22
+ return Date.parse(offer.expires_at) <= now ? "expired" : "open";
23
+ }
24
+ /** Time expiry is authoritative even after the exact task generation becomes unavailable. */
25
+ export function collaborationOfferStatus(offer: ExocomOffer, tasks: readonly CollaborationTask[], now: number): "open" | "expired" | "stale" {
26
+ if (offerStatus(offer, now) === "expired") return "expired";
27
+ const task = tasks.find((candidate) => candidate.created.work_key === offer.work_key && candidate.created.msg_id === offer.task_id);
28
+ return task && collaborationStatus(task) === "open" ? "open" : "stale";
29
+ }
30
+ function bounded(state: CollaborationState, reserveLifecycle = true): boolean {
31
+ // Local ledger lifecycle events are unsigned. Reserve their largest identity metadata and a
32
+ // minimal terminal note, as well as lease loss, before admitting optional prose. A full note
33
+ // can hit quota; taking work, finishing with a short note and release must remain available.
34
+ const reserved = { ...state, tasks: state.tasks.map((task) => {
35
+ if (task.finished) return task;
36
+ const future = { work_key: task.created.work_key, task_id: task.created.msg_id, from_session: "x".repeat(128),
37
+ from_name: "界".repeat(256), msg_id: "x".repeat(128), ts: "9999-12-31T23:59:59.999Z" };
38
+ return { ...task, stale: true, staleAt: -Number.MAX_VALUE,
39
+ taken: task.taken ?? { ...future, kind: "task_take" }, finished: { ...future, kind: "task_finish", outcome: "abandoned", note: "x" } };
40
+ }) };
41
+ return state.tasks.length <= COLLABORATION_LIMITS.tasks && state.offers.length <= COLLABORATION_LIMITS.offers
42
+ && state.evidence.length <= COLLABORATION_LIMITS.evidence && Buffer.byteLength(JSON.stringify(reserveLifecycle ? reserved : state), "utf8") <= COLLABORATION_LIMITS.maxBytes;
43
+ }
44
+ /** Retained facts outlive the rolling transport-id window; they must never acquire duplicate identities. */
45
+ export function retainedCollaborationEvent(state: CollaborationState, msgId: string): ExocomSemanticFrame | undefined {
46
+ for (const task of state.tasks) {
47
+ for (const event of [task.created, task.taken, task.progress, task.finished]) if (event?.msg_id === msgId) return event;
48
+ }
49
+ return state.offers.find((event) => event.msg_id === msgId) ?? state.evidence.find((event) => event.msg_id === msgId);
50
+ }
51
+ type Transition = { ok: true; state: CollaborationState } | { ok: false; error: string };
52
+ function transition(state: CollaborationState, event: ExocomCollaborationFrame | ExocomProgress, reserveLifecycle: boolean): Transition {
53
+ if (event.kind === "progress" && event.task_id === undefined) return { ok: true, state };
54
+ const task = state.tasks.find((value) => value.created.work_key === event.work_key);
55
+ let next = state;
56
+ if (event.kind === "task_create") {
57
+ if (task) return { ok: false, error: "task work_key already exists" };
58
+ for (const path of event.write_set) {
59
+ const error = writeSetPathError(path);
60
+ if (error) return { ok: false, error: `task write_set path is ${error}` };
61
+ }
62
+ next = { ...state, tasks: [...state.tasks, { created: event }] };
63
+ } else {
64
+ if (!task) return { ok: false, error: "unknown collaboration task" };
65
+ if (event.task_id !== task.created.msg_id) return { ok: false, error: "task_id does not match this work key's retained generation" };
66
+ if (event.kind === "publish") next = { ...state, evidence: [...state.evidence, event] };
67
+ else if (event.kind === "offer") {
68
+ if (state.offers.some((offer) => offer.offer_id === event.offer_id)) return { ok: false, error: "offer_id already exists" };
69
+ if (event.to_session === event.from_session) return { ok: false, error: "offer cannot target self" };
70
+ if (collaborationStatus(task) !== "open") return { ok: false, error: "offers require an open task" };
71
+ next = { ...state, offers: [...state.offers, event] };
72
+ } else {
73
+ const status = collaborationStatus(task);
74
+ if (event.kind === "task_take") {
75
+ if (status !== "open") return { ok: false, error: `task is ${status}; pickup refused` };
76
+ next = { ...state, tasks: state.tasks.map((value) => value === task ? { ...task, taken: event } : value) };
77
+ } else {
78
+ if (status !== "taken" || task.taken?.from_session !== event.from_session) return { ok: false, error: "only the active task taker may report progress or finish" };
79
+ if (event.kind === "progress" && Date.parse(event.ts) < Date.parse(task.progress?.ts ?? task.taken.ts)) return { ok: false, error: "task progress predates its latest observation" };
80
+ const updated = event.kind === "progress" ? { ...task, progress: event } : { ...task, finished: event };
81
+ next = { ...state, tasks: state.tasks.map((value) => value === task ? updated : value) };
82
+ }
83
+ }
84
+ }
85
+ return bounded(next, reserveLifecycle) ? { ok: true, state: next } : { ok: false, error: "collaboration retention budget reached; shorten optional report text" };
86
+ }
87
+ export function applyCollaborationEvent(state: CollaborationState, event: ExocomCollaborationFrame | ExocomProgress): Transition {
88
+ return transition(state, event, true);
89
+ }
90
+
91
+ export function staleCollaboration(state: CollaborationState, isLive: (session: string) => boolean, now: number): CollaborationState {
92
+ return { ...state, tasks: state.tasks.map((task) => task.finished || task.stale || isLive(task.taken?.from_session ?? task.created.from_session)
93
+ ? task : { ...task, stale: true, staleAt: now }) };
94
+ }
95
+ export function releaseCollaboration(state: CollaborationState, session: string, workKey: string, now: number): CollaborationState {
96
+ return { ...state, tasks: state.tasks.map((task) => task.created.work_key === workKey && !task.finished && !task.stale
97
+ && (task.taken?.from_session ?? task.created.from_session) === session ? { ...task, stale: true, staleAt: now } : task) };
98
+ }
99
+ export function pruneCollaboration(state: CollaborationState, now: number, ttlMs: number, isLive: (session: string) => boolean): CollaborationState {
100
+ const reconciled = staleCollaboration(state, isLive, now);
101
+ const tasks = reconciled.tasks.filter((task) => {
102
+ const terminalAt = task.finished ? Date.parse(task.finished.ts) : task.staleAt;
103
+ return terminalAt === undefined || now - terminalAt <= ttlMs;
104
+ });
105
+ const retained = new Set(tasks.map((task) => task.created.work_key));
106
+ return {
107
+ tasks,
108
+ offers: state.offers.filter((offer) => retained.has(offer.work_key) && now - Date.parse(offer.expires_at) <= ttlMs),
109
+ evidence: state.evidence.filter((item) => retained.has(item.work_key) && now - Date.parse(item.ts) <= ttlMs),
110
+ };
111
+ }
112
+
113
+ /** Reconstruct snapshots through the same rules. No malformed ownership record is silently skipped. */
114
+ export function parseCollaboration(raw: unknown): CollaborationState | undefined {
115
+ if (!raw || typeof raw !== "object") return undefined;
116
+ const value = raw as CollaborationState;
117
+ if (!Array.isArray(value.tasks) || !Array.isArray(value.offers) || !Array.isArray(value.evidence) || !bounded(value)) return undefined;
118
+ let state = emptyCollaboration();
119
+ const seen = new Set<string>();
120
+ function replay(event: unknown, kind: string): boolean {
121
+ if (!isSemanticFrame(event) || event.kind !== kind || seen.has(event.msg_id)) return false;
122
+ if (!(event.kind === "task_create" || event.kind === "task_take" || event.kind === "task_finish" || event.kind === "progress" || event.kind === "offer" || event.kind === "publish")) return false;
123
+ // Definitions must precede historical offers. Replay checks physical bounds; reserve
124
+ // future lifecycle only on the final state, once completed tasks are terminal again.
125
+ const applied = transition(state, event, false);
126
+ if (!applied.ok) return false;
127
+ seen.add(event.msg_id);
128
+ state = applied.state;
129
+ return true;
130
+ }
131
+ // All definitions precede offers/evidence; a finished task's historical offer is valid.
132
+ for (const task of value.tasks) {
133
+ if (!task || typeof task !== "object" || !replay(task.created, "task_create")) return undefined;
134
+ }
135
+ for (const offer of value.offers) if (!replay(offer, "offer")) return undefined;
136
+ for (const task of value.tasks) {
137
+ for (const [event, kind] of [[task.taken, "task_take"], [task.progress, "progress"], [task.finished, "task_finish"]] as const) {
138
+ if (event !== undefined && (event.work_key !== task.created.work_key || !replay(event, kind))) return undefined;
139
+ }
140
+ if (task.stale !== undefined && task.stale !== true) return undefined;
141
+ if (task.stale) {
142
+ if (task.finished || typeof task.staleAt !== "number" || !Number.isFinite(task.staleAt)) return undefined;
143
+ state = releaseCollaboration(state, task.taken?.from_session ?? task.created.from_session, task.created.work_key, task.staleAt);
144
+ } else if (task.staleAt !== undefined) return undefined;
145
+ }
146
+ for (const evidence of value.evidence) if (!replay(evidence, "publish")) return undefined;
147
+ return bounded(state) ? state : undefined;
148
+ }
@@ -31,13 +31,20 @@ export interface ExocomAnswer {
31
31
  }
32
32
  export interface ExocomProgress {
33
33
  kind: "progress"; work_key: string; from_session: string; from_name: string;
34
- note: string; msg_id: string; ts: string; signature?: string;
34
+ note: string; msg_id: string; ts: string; signature?: string; task_id?: string;
35
35
  }
36
36
  export interface ExocomRelease {
37
37
  kind: "release"; work_key: string; from_session: string; from_name: string;
38
38
  msg_id: string; ts: string; signature?: string;
39
39
  }
40
- export type ExocomSemanticFrame = ExocomClaim | ExocomAsk | ExocomAnswer | ExocomProgress | ExocomRelease;
40
+ interface CollaborationFrame { work_key: string; from_session: string; from_name: string; msg_id: string; ts: string; signature?: string; }
41
+ export interface ExocomTaskCreate extends CollaborationFrame { kind: "task_create"; description: string; write_set: string[]; }
42
+ export interface ExocomTaskTake extends CollaborationFrame { kind: "task_take"; task_id: string; }
43
+ export interface ExocomTaskFinish extends CollaborationFrame { kind: "task_finish"; task_id: string; outcome: "done" | "abandoned"; note: string; }
44
+ export interface ExocomOffer extends CollaborationFrame { kind: "offer"; task_id: string; offer_id: string; to_session?: string; expires_at: string; }
45
+ export interface ExocomPublish extends CollaborationFrame { kind: "publish"; task_id: string; title: string; excerpt: string; }
46
+ export type ExocomCollaborationFrame = ExocomTaskCreate | ExocomTaskTake | ExocomTaskFinish | ExocomOffer | ExocomPublish;
47
+ export type ExocomSemanticFrame = ExocomClaim | ExocomAsk | ExocomAnswer | ExocomProgress | ExocomRelease | ExocomCollaborationFrame;
41
48
  export type ExocomFrame = ExocomMessage | ExocomBye | ExocomAck | ExocomNack | ExocomSemanticFrame;
42
49
 
43
50
  const str = (v: unknown): v is string => typeof v === "string";
@@ -73,10 +80,27 @@ export function isSemanticFrame(v: unknown): v is ExocomSemanticFrame {
73
80
  case "progress":
74
81
  return token(o.work_key) && token(o.from_session) && metadata(o.from_name, 256)
75
82
  && bounded(o.note, 4_096) && token(o.msg_id) && timestamp(o.ts)
76
- && optionalBounded(o.signature, 512);
83
+ && optionalBounded(o.signature, 512) && (o.task_id === undefined || token(o.task_id));
77
84
  case "release":
78
85
  return token(o.work_key) && token(o.from_session) && metadata(o.from_name, 256)
79
86
  && token(o.msg_id) && timestamp(o.ts) && optionalBounded(o.signature, 512);
87
+ case "task_create":
88
+ case "task_take":
89
+ case "task_finish":
90
+ case "offer":
91
+ case "publish": {
92
+ if (!token(o.work_key) || !token(o.from_session) || !metadata(o.from_name, 256)
93
+ || !token(o.msg_id) || !timestamp(o.ts) || !optionalBounded(o.signature, 512)) return false;
94
+ if (o.kind === "task_create") return bounded(o.description, 2_048) && Array.isArray(o.write_set)
95
+ && (o.write_set.length === 0 || writeSet(o.write_set));
96
+ if (!token(o.task_id)) return false;
97
+ if (o.kind === "task_finish") return (o.outcome === "done" || o.outcome === "abandoned") && bounded(o.note, 4_096);
98
+ if (o.kind === "offer") return token(o.offer_id) && (o.to_session === undefined || token(o.to_session))
99
+ && timestamp(o.expires_at) && Date.parse(o.expires_at) > Date.parse(o.ts)
100
+ && Date.parse(o.expires_at) - Date.parse(o.ts) <= 86_400_000;
101
+ if (o.kind === "publish") return bounded(o.title, 160) && bounded(o.excerpt, 4_096) && Buffer.byteLength(o.excerpt, "utf8") <= 8_192;
102
+ return true;
103
+ }
80
104
  default:
81
105
  return false;
82
106
  }
@@ -102,6 +126,11 @@ export function isExocomFrame(v: unknown): v is ExocomFrame {
102
126
  case "answer":
103
127
  case "progress":
104
128
  case "release":
129
+ case "task_create":
130
+ case "task_take":
131
+ case "task_finish":
132
+ case "offer":
133
+ case "publish":
105
134
  return isSemanticFrame(v);
106
135
  default: return false;
107
136
  }
@@ -127,9 +156,19 @@ export function frameSigningPayload(frame: ExocomFrame): string {
127
156
  frame.ok, frame.evidence, frame.ts]);
128
157
  case "progress":
129
158
  return JSON.stringify([frame.kind, frame.msg_id, frame.work_key, frame.from_session, frame.from_name,
130
- frame.note, frame.ts]);
159
+ frame.note, frame.ts, frame.task_id ?? null]);
131
160
  case "release":
132
161
  return JSON.stringify([frame.kind, frame.msg_id, frame.work_key, frame.from_session, frame.from_name, frame.ts]);
162
+ case "task_create":
163
+ return JSON.stringify([frame.kind, frame.msg_id, frame.work_key, frame.from_session, frame.from_name, frame.description, frame.write_set, frame.ts]);
164
+ case "task_take":
165
+ return JSON.stringify([frame.kind, frame.msg_id, frame.work_key, frame.task_id, frame.from_session, frame.from_name, frame.ts]);
166
+ case "task_finish":
167
+ return JSON.stringify([frame.kind, frame.msg_id, frame.work_key, frame.task_id, frame.from_session, frame.from_name, frame.outcome, frame.note, frame.ts]);
168
+ case "offer":
169
+ return JSON.stringify([frame.kind, frame.msg_id, frame.offer_id, frame.work_key, frame.task_id, frame.from_session, frame.from_name, frame.to_session ?? null, frame.expires_at, frame.ts]);
170
+ case "publish":
171
+ return JSON.stringify([frame.kind, frame.msg_id, frame.work_key, frame.task_id, frame.from_session, frame.from_name, frame.title, frame.excerpt, frame.ts]);
133
172
  }
134
173
  }
135
174
 
@@ -1,6 +1,11 @@
1
1
  /** Identity metadata does not claim work or settle asks. Persona denials still apply first. */
2
2
  const ALLOWED = new Set(["exocom_name", "exocom_answer", "exocom_decline", "exocom_status", "read", "grep", "find", "ls"]);
3
3
 
4
- export function constrainedTurnAllows(toolName: string): boolean {
5
- return ALLOWED.has(toolName);
4
+ export function constrainedTurnAllows(toolName: string, input?: unknown): boolean {
5
+ if (ALLOWED.has(toolName)) return true;
6
+ if (!input || typeof input !== "object") return false;
7
+ const action = (input as { action?: unknown }).action;
8
+ return (toolName === "exocom_task" && (action === "list" || action === "get"))
9
+ || (toolName === "exocom_publish" && action === "read")
10
+ || (toolName === "exocom_profile" && action === "get");
6
11
  }
@@ -13,6 +13,7 @@ import { canCallTool, canParticipateInExocom, EXOCOM_TOOL_NAMES } from "../core/
13
13
  import type { DelegationNudge } from "../core/nudge.ts";
14
14
  import { shortModel } from "../tools/delegate.ts";
15
15
  import { boundDisplayRows } from "../ui/presentation.ts";
16
+ import { compactWidget } from "../ui/compact-widget.ts";
16
17
  import type { PersonaController } from "../persona/controller.ts";
17
18
  import { type InstanceDescriptor, type PeerDescriptor } from "../telemetry/contract.ts";
18
19
  import type { TelemetryProducer } from "../telemetry/producer.ts";
@@ -26,6 +27,7 @@ import {
26
27
  sanitizePeerField,
27
28
  sendPersonaFollowUp,
28
29
  shouldReportHeartbeatFailure,
30
+ toastCause,
29
31
  type ExocomQueuedToast,
30
32
  } from "../extension/shared.ts";
31
33
  import { SeenMessages, SenderBudget } from "./guards.ts";
@@ -53,6 +55,9 @@ import type { ExocomSemanticFrame } from "./envelope.ts";
53
55
  import { prune as pruneExocom, type RegistryEntry } from "./registry.ts";
54
56
  import { registerExocomTools } from "../tools/exocom.ts";
55
57
  import { registerExocomWorkTools } from "../tools/exocom-work.ts";
58
+ import { registerExocomCollaborationTools } from "../tools/exocom-collaboration.ts";
59
+ import { registerExocomProfileTool } from "../tools/exocom-profile.ts";
60
+ import { collaborationWriteIntent, deliverCollaborationOffer } from "./collaboration-delivery.ts";
56
61
  import type { SessionIdentity } from "../extension/identity.ts";
57
62
  import { WriteWarnings } from "./write-guard.ts";
58
63
 
@@ -236,28 +241,23 @@ export function installExocom(pi: ExtensionAPI, host: ExocomHost): ExocomInstall
236
241
  }
237
242
 
238
243
  function telemetrySessionId(ctx: ExtensionContext): string {
239
- return (ctx as ExtensionContext & { sessionManager?: { getSessionId?: () => string } }).sessionManager?.getSessionId?.() ?? `legacy-${process.pid}`;
244
+ return ctx.sessionManager.getSessionId();
240
245
  }
241
246
 
242
247
  function currentTelemetryInstance(ctx: ExtensionContext): InstanceDescriptor {
243
- const runtimeCtx = ctx as ExtensionContext & {
244
- sessionManager?: { getSessionId?: () => string };
245
- isIdle?: () => boolean;
246
- getContextUsage?: () => { percent?: number } | undefined;
247
- };
248
248
  const personaName = host.controller.activePersona?.name ?? "";
249
249
  const displayName = exocomSelfStatusLabel(host.identity.chosen, host.identity.name, personaName);
250
250
  return {
251
251
  displayName,
252
252
  persona: host.controller.activePersona?.name ?? "",
253
253
  model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "",
254
- status: runtimeCtx.isIdle?.() === true ? "idle" : "active",
254
+ status: ctx.isIdle() ? "idle" : "active",
255
255
  pid: process.pid,
256
256
  // Clamped HERE, not merely in the producer's `sanitizeInstance`: that one runs only for
257
257
  // `instance.started`, so an out-of-range reading would ride `instance.updated` and
258
258
  // `instance.heartbeat` out to consumers, where the contract rejects the WHOLE event over
259
259
  // the one bad field.
260
- contextPercent: Math.max(0, Math.min(100, Math.round(runtimeCtx.getContextUsage?.()?.percent ?? 0))),
260
+ contextPercent: Math.max(0, Math.min(100, Math.round(ctx.getContextUsage()?.percent ?? 0))),
261
261
  exocomEnabled: exocomPlane !== undefined,
262
262
  color: exocomColorFor(displayName),
263
263
  };
@@ -327,7 +327,7 @@ export function installExocom(pi: ExtensionAPI, host: ExocomHost): ExocomInstall
327
327
  return `${quiet ? "💤" : "📡"} ${name}${persona ? ` (${persona})` : ""} · ${shortModel(model) || "?"} · ctx ${contextPct}% · recv ${exocomPlane?.receivedFromPeer(p.session_id) ?? 0} · sent ${exocomPlane?.sentToPeer(p.session_id) ?? 0}${workspaceBit}`;
328
328
  });
329
329
  const lines = boundDisplayRows(local, peerRows, 7, "exocom_list for the full pool");
330
- host.lastCtx.ui.setWidget("persona-exocom", lines, { placement: "aboveEditor" });
330
+ host.lastCtx.ui.setWidget("persona-exocom", compactWidget(lines), { placement: "aboveEditor" });
331
331
  } catch {
332
332
  /* cosmetic — the widget is best-effort */
333
333
  }
@@ -411,7 +411,7 @@ export function installExocom(pi: ExtensionAPI, host: ExocomHost): ExocomInstall
411
411
  exocomSeen = new SeenMessages({ ttlMs: EXOCOM.SEEN_TTL_MS });
412
412
  exocomLedgerFile = ledgerPath(agentDir, hash);
413
413
  exocomWaitNotifier = new IdleCoalescingNotifier<string>({
414
- isIdle: () => canDeliverPersonaNotification(host.orchestrating, host.processingDeferredOrchestration, host.lastCtx?.isIdle?.() === true),
414
+ isIdle: () => canDeliverPersonaNotification(host.orchestrating, host.processingDeferredOrchestration, host.lastCtx?.isIdle() === true),
415
415
  deliver: (message) => sendPersonaFollowUp(pi, message, "pi-persona"),
416
416
  render: (items) => items.join("\n\n"),
417
417
  setTimer: (fn, ms) => { const h = setTimeout(fn, ms); h.unref?.(); return h; },
@@ -422,7 +422,7 @@ export function installExocom(pi: ExtensionAPI, host: ExocomHost): ExocomInstall
422
422
  // pi.sendMessage (a distinct, labellable custom message) rather than pi.sendUserMessage
423
423
  // (mirrors the bridge's own `sendFollowUp` for inbound cross-process text).
424
424
  exocomNotifier = new IdleCoalescingNotifier<string>({
425
- isIdle: () => canDeliverPersonaNotification(host.orchestrating, host.processingDeferredOrchestration, host.lastCtx?.isIdle?.() === true),
425
+ isIdle: () => canDeliverPersonaNotification(host.orchestrating, host.processingDeferredOrchestration, host.lastCtx?.isIdle() === true),
426
426
  deliver: (message) => sendPersonaFollowUp(pi, message, "exocom_received"),
427
427
  // A burst of independent peers must not turn one idle wake into an unbounded model
428
428
  // payload. Each item is already attributed and peer-fenced by buildInboundDelivery;
@@ -535,7 +535,7 @@ export function installExocom(pi: ExtensionAPI, host: ExocomHost): ExocomInstall
535
535
  if (!plane) throw new Error("exocom is not active for this persona");
536
536
  if (!exocomLedgerFile) throw new Error("exocom ledger is not available");
537
537
  let target: DisplayPeer | undefined;
538
- if (frame.kind === "ask") {
538
+ if (frame.kind === "ask" || (frame.kind === "offer" && frame.to_session !== undefined)) {
539
539
  target = plane.listPeers().find((peer) => peer.session_id === frame.to_session);
540
540
  if (!target) throw new Error(`exocom: unknown peer session "${frame.to_session}"`);
541
541
  } else if (frame.kind === "answer") {
@@ -549,7 +549,14 @@ export function installExocom(pi: ExtensionAPI, host: ExocomHost): ExocomInstall
549
549
  }
550
550
  }
551
551
  const prune = currentLedgerPruneOptions();
552
- const applied = commitLedgerEvent(exocomLedgerFile, frame, prune ? { prune } : {});
552
+ if (collaborationWriteIntent(frame, currentLedgerState())
553
+ && (exocomScope?.joined === true || (host.controller.capabilities && !canCallTool(host.controller.capabilities, "exocom_claim")))) {
554
+ throw new Error("exocom: this session cannot claim write paths in the selected scope");
555
+ }
556
+ const applied = commitLedgerEvent(exocomLedgerFile, frame, {
557
+ ...(prune ? { prune } : {}),
558
+ canClaim: exocomScope?.joined !== true && (!host.controller.capabilities || canCallTool(host.controller.capabilities, "exocom_claim")),
559
+ });
553
560
  if (!applied.ok) throw new Error(`exocom: ${applied.error}`);
554
561
  if (applied.duplicate === true) return { msg_id: frame.msg_id };
555
562
  // Claims/progress/releases are authoritative in the shared ledger and need no postcard.
@@ -561,7 +568,7 @@ export function installExocom(pi: ExtensionAPI, host: ExocomHost): ExocomInstall
561
568
  } catch { /* the durable result is still recoverable from the ledger */ }
562
569
  return { msg_id: frame.msg_id, peerWakeDeferred: true };
563
570
  }
564
- if (target && (frame.kind === "ask" || frame.kind === "answer")) {
571
+ if (target && (frame.kind === "ask" || frame.kind === "answer" || frame.kind === "offer")) {
565
572
  try {
566
573
  await plane.sendSemantic(target.target, frame);
567
574
  } catch {
@@ -656,7 +663,7 @@ export function installExocom(pi: ExtensionAPI, host: ExocomHost): ExocomInstall
656
663
  exocomNotifier?.notify(decision.deliver);
657
664
  // Never inject peer text into a live model turn. A compact human-only toast makes
658
665
  // receipt visible while a long tool loop keeps the follow-up idle-gated.
659
- if (host.lastCtx?.isIdle?.() !== true) {
666
+ if (host.lastCtx?.isIdle() !== true) {
660
667
  exocomToastNotifier?.notify({ label, inReplyTo: msg.in_reply_to });
661
668
  }
662
669
  }
@@ -691,7 +698,15 @@ export function installExocom(pi: ExtensionAPI, host: ExocomHost): ExocomInstall
691
698
  return { accepted: false, reason: error instanceof Error ? error.message : "ledger unavailable" };
692
699
  }
693
700
  const label = exocomPlane?.humanDisplayLabelFor(fromEntry) ?? fromEntry.session_id;
694
- if (frame.kind === "ask") {
701
+ if (fromEntry.workspace_id !== undefined && fromEntry.workspace_id !== scope.scopeWorkspaceId && collaborationWriteIntent(frame, state)) {
702
+ exocomSeen?.forget(frame.from_session, frame.msg_id);
703
+ return { accepted: false, reason: "external-workspace peers cannot take write tasks in the selected scope" };
704
+ }
705
+ if (frame.kind === "offer") {
706
+ const delivery = deliverCollaborationOffer(frame, state, sessionId, label, Date.now());
707
+ if (!delivery.accepted) { exocomSeen?.forget(frame.from_session, frame.msg_id); return delivery; }
708
+ if (delivery.notice) exocomNotifier?.notify(delivery.notice);
709
+ } else if (frame.kind === "ask") {
695
710
  if (frame.to_session !== sessionId) {
696
711
  exocomSeen?.forget(frame.from_session, frame.msg_id);
697
712
  return { accepted: false, reason: "ask addressed to another session" };
@@ -739,7 +754,7 @@ export function installExocom(pi: ExtensionAPI, host: ExocomHost): ExocomInstall
739
754
  if (shouldReportHeartbeatFailure(exocomHeartbeatFailures)) {
740
755
  try {
741
756
  host.lastCtx?.ui.notify(
742
- `exocom: heartbeat failed ${exocomHeartbeatFailures}× in a row (${message}) — peers drop this instance from the pool until it recovers.`,
757
+ `exocom: heartbeat failed ${exocomHeartbeatFailures}× in a row (${toastCause(message)}) — peers drop this instance from the pool until it recovers.`,
743
758
  "warning",
744
759
  );
745
760
  } catch {
@@ -774,6 +789,19 @@ export function installExocom(pi: ExtensionAPI, host: ExocomHost): ExocomInstall
774
789
  ledger: () => currentLedgerState(),
775
790
  labelFor: peerLabelFor,
776
791
  });
792
+ registerExocomCollaborationTools(pi, {
793
+ getPlane: () => exocomPlane,
794
+ sessionId: () => sessionId, name: () => host.identity.name, now: () => Date.now(),
795
+ canClaim: () => exocomScope?.joined !== true,
796
+ canCallTool: (name) => !host.controller.capabilities || canCallTool(host.controller.capabilities, name),
797
+ resolveTarget: (target) => {
798
+ if (!exocomPlane) throw new Error("exocom is not active for this persona");
799
+ return exocomPlane.resolvePeer(target).session_id;
800
+ },
801
+ dispatch: dispatchSemantic, ledger: currentLedgerState, labelFor: peerLabelFor,
802
+ targetForSession: (id) => exocomPlane?.listPeers().find((peer) => peer.session_id === id)?.target,
803
+ });
804
+ registerExocomProfileTool(pi, () => exocomPlane);
777
805
  } catch (err) {
778
806
  // `plane.start()` may have bound its server before a later registry write failed.
779
807
  // Teardown while the reference is still owned; this also cancels notifier/timers.
@@ -782,7 +810,7 @@ export function installExocom(pi: ExtensionAPI, host: ExocomHost): ExocomInstall
782
810
  let surfacedInUi = false;
783
811
  if (ctx.hasUI) {
784
812
  try {
785
- ctx.ui.notify(`exocom failed to start: ${message}`, "error");
813
+ ctx.ui.notify(`exocom failed to start: ${toastCause(message)}`, "error");
786
814
  surfacedInUi = true;
787
815
  } catch {
788
816
  /* the session may already be tearing down; stderr remains available */
@@ -890,6 +918,7 @@ export function installExocom(pi: ExtensionAPI, host: ExocomHost): ExocomInstall
890
918
  const workKeys = new Set([
891
919
  ...state.claims.filter((claim) => claim.from_session === exocomSessionId).map((claim) => claim.work_key),
892
920
  ...state.asks.filter((ask) => ask.from_session === exocomSessionId).map((ask) => ask.work_key),
921
+ ...(state.collaboration?.tasks.filter((task) => !task.finished && (task.taken?.from_session ?? task.created.from_session) === exocomSessionId).map((task) => task.created.work_key) ?? []),
893
922
  ]);
894
923
  for (const work_key of workKeys) {
895
924
  const prune = currentLedgerPruneOptions();