jeo-code 0.12.10 → 0.12.12

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 (38) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.ja.md +17 -12
  3. package/README.ko.md +86 -27
  4. package/README.md +76 -20
  5. package/README.zh.md +17 -12
  6. package/package.json +2 -1
  7. package/src/agent/ast-edit-tool.ts +3 -1
  8. package/src/agent/engine.ts +114 -16
  9. package/src/agent/goal-tool.ts +7 -1
  10. package/src/agent/goal-verifier.ts +95 -16
  11. package/src/agent/loop-guards.ts +10 -9
  12. package/src/agent/lsp-rename-tool.ts +3 -1
  13. package/src/agent/notify/discord-daemon.ts +3 -1
  14. package/src/agent/notify/paths.ts +8 -0
  15. package/src/agent/notify/session-endpoint.ts +23 -4
  16. package/src/agent/notify/slack-daemon.ts +2 -2
  17. package/src/agent/notify/telegram-daemon.ts +223 -89
  18. package/src/agent/notify/topic-registry.ts +118 -15
  19. package/src/agent/skill-router.ts +31 -5
  20. package/src/agent/subagent-registry.ts +87 -5
  21. package/src/agent/subagent-tool.ts +133 -18
  22. package/src/agent/subagents.ts +7 -5
  23. package/src/agent/task-tool.ts +85 -14
  24. package/src/agent/tools.ts +14 -4
  25. package/src/cli.ts +1 -0
  26. package/src/commands/launch.ts +145 -17
  27. package/src/commands/messenger.ts +5 -1
  28. package/src/commands/notify.ts +5 -6
  29. package/src/commands/skills.ts +9 -0
  30. package/src/commands/team.ts +72 -53
  31. package/src/messenger/activity.ts +13 -17
  32. package/src/messenger/config.ts +6 -2
  33. package/src/messenger/discord-setup.ts +3 -1
  34. package/src/messenger/discord.ts +16 -19
  35. package/src/messenger/gateway.ts +7 -8
  36. package/src/messenger/session-pool.ts +99 -13
  37. package/src/messenger/types.ts +4 -5
  38. package/src/skills/catalog.ts +19 -2
@@ -30,40 +30,127 @@ export interface TopicRecord {
30
30
  export interface TopicRegistryState {
31
31
  /** sessionId -> record. */
32
32
  topics: Record<string, TopicRecord>;
33
+ /** Bot account identity used to scope persisted topic ownership. */
34
+ botId?: string;
35
+ /** Paired Telegram chat identity used to scope persisted topic ownership. */
36
+ chatId?: string;
33
37
  }
34
38
 
35
39
  export function emptyTopicRegistryState(): TopicRegistryState {
36
40
  return { topics: {} };
37
41
  }
38
42
 
43
+ /** Runtime guards for persisted topic state and API-created records. */
44
+ function isRecordObject(value: unknown): value is Record<string, unknown> {
45
+ return typeof value === "object" && value !== null && !Array.isArray(value);
46
+ }
47
+
48
+ function isValidTopicId(value: unknown): value is number {
49
+ return typeof value === "number" && Number.isFinite(value) && Number.isSafeInteger(value) && value > 0;
50
+ }
51
+
52
+ function isTopicRecord(value: unknown): value is TopicRecord {
53
+ if (!isRecordObject(value) || !isValidTopicId(value.topicId) ||
54
+ typeof value.createdAt !== "number" || !Number.isFinite(value.createdAt)) return false;
55
+ return value.name === undefined || typeof value.name === "string";
56
+ }
57
+
58
+ function validateTopicRecord(sessionId: string, value: unknown): TopicRecord {
59
+ if (!isRecordObject(value)) throw new Error(`Invalid topic registry record for session '${sessionId}'`);
60
+ if (!isValidTopicId(value.topicId)) throw new Error(`Invalid topic ID for session '${sessionId}'`);
61
+ if (typeof value.createdAt !== "number" || !Number.isFinite(value.createdAt)) {
62
+ throw new Error(`Invalid creation timestamp for session '${sessionId}'`);
63
+ }
64
+ if (value.name !== undefined && typeof value.name !== "string") {
65
+ throw new Error(`Invalid topic name for session '${sessionId}'`);
66
+ }
67
+ if (!isTopicRecord(value)) throw new Error(`Invalid topic registry record for session '${sessionId}'`);
68
+ return value;
69
+ }
70
+
71
+ interface TopicRegistryIdentity {
72
+ botId: string;
73
+ chatId: string;
74
+ }
75
+
76
+ function parseTopicRegistryIdentity(state: Record<string, unknown>): TopicRegistryIdentity | undefined {
77
+ const hasBotId = Object.hasOwn(state, "botId");
78
+ const hasChatId = Object.hasOwn(state, "chatId");
79
+ if (!hasBotId && !hasChatId) return undefined;
80
+ if (!hasBotId || !hasChatId || typeof state.botId !== "string" || !state.botId ||
81
+ typeof state.chatId !== "string" || !state.chatId) {
82
+ throw new Error("Invalid topic registry identity: botId and chatId must be non-empty strings");
83
+ }
84
+ return { botId: state.botId, chatId: state.chatId };
85
+ }
86
+
87
+ function sameTopicRegistryIdentity(a: TopicRegistryIdentity, b: TopicRegistryIdentity | undefined): boolean {
88
+ return b !== undefined && a.botId === b.botId && a.chatId === b.chatId;
89
+ }
90
+
39
91
  /**
40
92
  * In-memory registry over a serializable state. Topic creation is injected via
41
93
  * `getOrCreateTopic`'s `create` callback (the daemon supplies a real
42
94
  * `createForumTopic` call); reuse-on-resume is automatic when a record exists.
43
95
  */
44
96
  export class TopicRegistry {
45
- private readonly topics: Map<string, TopicRecord>;
97
+ private topics: Map<string, TopicRecord>;
46
98
  /** Maps topicId -> sessionId for fast inbound routing. */
47
- private readonly byTopic = new Map<number, string>();
99
+ private byTopic = new Map<number, string>();
48
100
  /** In-flight create promises, keyed by session, to dedupe concurrent creates. */
49
101
  private readonly inflight = new Map<string, Promise<TopicRecord>>();
102
+ /** Invalidates in-flight creates when a validated state replaces the indexes. */
103
+ private revision = 0;
104
+ /** Identity of the persisted Telegram destination currently loaded. */
105
+ private identity: TopicRegistryIdentity | undefined;
50
106
 
51
107
  constructor(state: TopicRegistryState = emptyTopicRegistryState()) {
52
- this.topics = new Map(Object.entries(state.topics ?? {}));
53
- for (const [sessionId, record] of this.topics) this.byTopic.set(record.topicId, sessionId);
108
+ this.topics = new Map();
109
+ this.load(state);
54
110
  }
55
-
56
- /** Merge a serialized state into this registry, preserving all persisted fields. */
111
+ /** Merge a serialized state, replacing indexes atomically within its identity. */
57
112
  load(state: TopicRegistryState): void {
58
- for (const [sessionId, record] of Object.entries(state.topics ?? {})) {
59
- this.topics.set(sessionId, record);
60
- this.byTopic.set(record.topicId, sessionId);
113
+ if (!isRecordObject(state) || !isRecordObject(state.topics)) {
114
+ throw new Error("Invalid topic registry state: topics must be an object");
115
+ }
116
+
117
+ const incomingIdentity = parseTopicRegistryIdentity(state);
118
+ const resetForIdentity = this.identity !== undefined
119
+ ? !sameTopicRegistryIdentity(this.identity, incomingIdentity)
120
+ : incomingIdentity !== undefined && this.topics.size > 0;
121
+ const nextTopics = resetForIdentity ? new Map<string, TopicRecord>() : new Map(this.topics);
122
+ for (const [sessionId, rawRecord] of Object.entries(state.topics)) {
123
+ nextTopics.set(sessionId, validateTopicRecord(sessionId, rawRecord));
61
124
  }
125
+
126
+ const nextByTopic = new Map<number, string>();
127
+ for (const [sessionId, rawRecord] of nextTopics) {
128
+ const record = validateTopicRecord(sessionId, rawRecord);
129
+ const owner = nextByTopic.get(record.topicId);
130
+ if (owner !== undefined) {
131
+ throw new Error(`Duplicate topic ID ${record.topicId} for sessions '${owner}' and '${sessionId}'`);
132
+ }
133
+ nextByTopic.set(record.topicId, sessionId);
134
+ }
135
+
136
+ this.topics = nextTopics;
137
+ this.byTopic = nextByTopic;
138
+ this.identity = incomingIdentity ?? (resetForIdentity ? undefined : this.identity);
139
+ this.revision++;
140
+ this.inflight.clear();
62
141
  }
63
142
 
64
143
  /** Resolve the owning session for a topic id (for fail-closed inbound routing). */
65
144
  sessionForTopic(topicId: number): string | undefined {
66
- return this.byTopic.get(topicId);
145
+ if (!isValidTopicId(topicId)) return undefined;
146
+ const sessionId = this.byTopic.get(topicId);
147
+ if (sessionId === undefined) return undefined;
148
+ const record = this.topics.get(sessionId);
149
+ if (!record || !isTopicRecord(record) || record.topicId !== topicId) {
150
+ this.byTopic.delete(topicId);
151
+ return undefined;
152
+ }
153
+ return sessionId;
67
154
  }
68
155
 
69
156
  /** All session ids with a persisted topic record. */
@@ -87,16 +174,29 @@ export class TopicRegistry {
87
174
  name?: string,
88
175
  ): Promise<TopicRecord> {
89
176
  const existing = this.topics.get(sessionId);
90
- if (existing) return existing;
177
+ if (existing) {
178
+ if (!isTopicRecord(existing)) throw new Error(`Invalid topic registry record for session '${sessionId}'`);
179
+ if (this.byTopic.get(existing.topicId) !== sessionId) {
180
+ throw new Error(`Topic registry mapping for session '${sessionId}' is invalid`);
181
+ }
182
+ return existing;
183
+ }
91
184
  // Concurrency guard: many session frames (identity/idle/turn/ask) can race
92
185
  // to first-use the same session. Without this, each call passes the
93
186
  // `existing` check before `create()` resolves and creates a DUPLICATE
94
187
  // forum topic. Share a single in-flight create per session id.
95
188
  const pending = this.inflight.get(sessionId);
96
189
  if (pending) return pending;
190
+ const revision = this.revision;
97
191
  const promise = (async () => {
98
192
  const topicId = await create();
99
- const record: TopicRecord = { topicId, name, createdAt: now() };
193
+ if (revision !== this.revision) throw new Error("Topic registry changed while creating a topic");
194
+ if (!isValidTopicId(topicId)) throw new Error("Invalid topic ID returned by topic creation");
195
+ const owner = this.byTopic.get(topicId);
196
+ if (owner !== undefined && owner !== sessionId) {
197
+ throw new Error(`Duplicate topic ID ${topicId} already belongs to session '${owner}'`);
198
+ }
199
+ const record = validateTopicRecord(sessionId, { topicId, name, createdAt: now() });
100
200
  this.topics.set(sessionId, record);
101
201
  this.byTopic.set(topicId, sessionId);
102
202
  return record;
@@ -105,7 +205,7 @@ export class TopicRegistry {
105
205
  try {
106
206
  return await promise;
107
207
  } finally {
108
- this.inflight.delete(sessionId);
208
+ if (this.inflight.get(sessionId) === promise) this.inflight.delete(sessionId);
109
209
  }
110
210
  }
111
211
 
@@ -141,12 +241,15 @@ export class TopicRegistry {
141
241
  const record = this.topics.get(sessionId);
142
242
  if (!record) return false;
143
243
  this.topics.delete(sessionId);
144
- this.byTopic.delete(record.topicId);
244
+ for (const [topicId, owner] of this.byTopic) {
245
+ if (owner === sessionId) this.byTopic.delete(topicId);
246
+ }
145
247
  return true;
146
248
  }
147
249
 
148
250
  /** Serialize for atomic persistence beside the daemon state. */
149
251
  serialize(): TopicRegistryState {
150
- return { topics: Object.fromEntries(this.topics) };
252
+ const topics = Object.fromEntries(this.topics);
253
+ return this.identity ? { topics, ...this.identity } : { topics };
151
254
  }
152
255
  }
@@ -43,9 +43,28 @@ export interface SkillRouteSelection {
43
43
  /** Advisory only — never an install or model instruction. */
44
44
  routing: string;
45
45
  }
46
-
47
46
  const TOKEN_RE = /[a-z0-9][a-z0-9._-]*/gi;
48
47
 
48
+ interface CachedSkillTokens {
49
+ textTokens: Set<string>;
50
+ nameTokens: Set<string>;
51
+ }
52
+
53
+ const skillTokenCache = new WeakMap<SkillDoc, CachedSkillTokens>();
54
+
55
+ function getSkillTokens(skill: SkillDoc): CachedSkillTokens {
56
+ let cached = skillTokenCache.get(skill);
57
+ if (!cached) {
58
+ const text = skillText(skill);
59
+ cached = {
60
+ textTokens: tokens(text),
61
+ nameTokens: tokens(skill.name.replace(/[:/]/g, " ")),
62
+ };
63
+ skillTokenCache.set(skill, cached);
64
+ }
65
+ return cached;
66
+ }
67
+
49
68
  /** Closed-class / low-signal words excluded from routing overlap — without
50
69
  * this, chit-chat ("how are you") spuriously matches every long skill doc. */
51
70
  const STOPWORDS = new Set([
@@ -78,8 +97,11 @@ function skillText(s: SkillDoc): string {
78
97
  export function scoreSkillForTask(task: string, skill: SkillDoc): { score: number; reasons: string[] } {
79
98
  const taskTokens = tokens(task);
80
99
  if (taskTokens.size === 0) return { score: 0, reasons: [] };
81
- const text = skillText(skill);
82
- const textTokens = tokens(text);
100
+ return scoreSkillWithTaskTokens(taskTokens, skill);
101
+ }
102
+
103
+ function scoreSkillWithTaskTokens(taskTokens: Set<string>, skill: SkillDoc): { score: number; reasons: string[] } {
104
+ const { textTokens, nameTokens } = getSkillTokens(skill);
83
105
  let overlap = 0;
84
106
  const reasons: string[] = [];
85
107
  for (const t of taskTokens) {
@@ -88,7 +110,6 @@ export function scoreSkillForTask(task: string, skill: SkillDoc): { score: numbe
88
110
  if (reasons.length < 5) reasons.push(t);
89
111
  }
90
112
  }
91
- const nameTokens = tokens(skill.name.replace(/[:/]/g, " "));
92
113
  let nameHits = 0;
93
114
  for (const t of taskTokens) if (nameTokens.has(t)) nameHits++;
94
115
  // Overlap ratio over task vocabulary + a decisive name bonus: an explicit
@@ -98,6 +119,7 @@ export function scoreSkillForTask(task: string, skill: SkillDoc): { score: numbe
98
119
  return { score, reasons };
99
120
  }
100
121
 
122
+
101
123
  /**
102
124
  * Jev-style `route-skills`: heuristic top-k skill selection for one task.
103
125
  * Never throws; empty input yields explicit none with reason "gate".
@@ -109,8 +131,12 @@ export function routeSkillsForTask(task: string, skills: SkillDoc[], topK = SKIL
109
131
  if (!trimmed) {
110
132
  return { topK: [], reason: "gate", strategy, routing: "No task text — no skill suggested. Answer directly." };
111
133
  }
134
+ const taskTokens = tokens(trimmed);
135
+ if (taskTokens.size === 0) {
136
+ return { topK: [], reason: "gate", strategy, routing: "No skill cleared the gate — answer directly without a workflow skill." };
137
+ }
112
138
  const scored = skills.map(s => {
113
- const { score, reasons } = scoreSkillForTask(trimmed, s);
139
+ const { score, reasons } = scoreSkillWithTaskTokens(taskTokens, s);
114
140
  return { name: s.name, score, reasons };
115
141
  });
116
142
  scored.sort((a, b) => b.score - a.score || (a.name < b.name ? -1 : 1));
@@ -57,7 +57,7 @@ export function truncateDetachedResult(s: string, maxBytes = MAX_DETACHED_RESULT
57
57
  return headSlice + marker + tailSlice;
58
58
  }
59
59
 
60
- export type SubagentStatus = "running" | "completed" | "failed" | "cancelled";
60
+ export type SubagentStatus = "running" | "paused" | "completed" | "failed" | "cancelled";
61
61
 
62
62
  export interface SubagentRecord {
63
63
  /** Stable id, e.g. "executor-1". */
@@ -72,12 +72,22 @@ export interface SubagentRecord {
72
72
  success?: boolean;
73
73
  /** Final subagent report/output, set once the run settles. */
74
74
  result?: string;
75
+ /** True when delegated execution may have changed workspace state. */
76
+ mutationMayHaveOccurred?: boolean;
77
+ /** Live or last known activity description (e.g. current tool being run). */
78
+ activity?: string;
79
+ /** True when the subagent can be resumed with follow-up instructions. */
80
+ resumable?: boolean;
81
+
75
82
  }
76
83
 
84
+
77
85
  interface Entry {
78
86
  record: SubagentRecord;
79
87
  promise: Promise<void>;
80
88
  abort: AbortController;
89
+ readOnly: boolean;
90
+ settled: boolean;
81
91
  }
82
92
 
83
93
  /** A detached run: receives its own AbortSignal and its own registry id, and
@@ -87,13 +97,18 @@ interface Entry {
87
97
  * streaming live events itself. */
88
98
  export type DetachedRunner = (signal: AbortSignal, id: string) => Promise<ToolResult>;
89
99
 
100
+ export const DEFAULT_PAUSE_DIRECTIVE =
101
+ "Stop at the next safe boundary and yield. You can be resumed later with more instructions.";
102
+ export const DEFAULT_RESUME_MESSAGE = "Continue from where you left off.";
103
+
90
104
  export class SubagentRegistry {
91
105
  private readonly entries = new Map<string, Entry>();
92
106
  private readonly seq = new Map<string, number>();
93
107
  private readonly steerInboxes = new Map<string, string[]>();
108
+ private readonly pausedIds = new Set<string>();
94
109
 
95
110
  /** Register and START a detached run; returns the (running) record immediately. */
96
- launch(role: string, task: string, runner: DetachedRunner): SubagentRecord {
111
+ launch(role: string, task: string, runner: DetachedRunner, options: { readOnly?: boolean } = {}): SubagentRecord {
97
112
  const n = (this.seq.get(role) ?? 0) + 1;
98
113
  this.seq.set(role, n);
99
114
  const id = `${role}-${n}`;
@@ -106,24 +121,35 @@ export class SubagentRegistry {
106
121
  startedAt: Date.now(),
107
122
  };
108
123
  this.steerInboxes.set(id, []);
109
- const promise = (async () => {
124
+ const entry: Entry = { record, promise: Promise.resolve(), abort, readOnly: options.readOnly ?? false, settled: false };
125
+ this.entries.set(id, entry);
126
+ entry.promise = (async () => {
110
127
  try {
111
128
  const res = await runner(abort.signal, id);
129
+ // Preserve structured mutation evidence even when cancellation already fired;
130
+ // the runner may have completed work before its promise settled.
131
+ record.mutationMayHaveOccurred = res.mutationMayHaveOccurred;
112
132
  // A cancel that already fired wins — don't overwrite the terminal state.
113
133
  if (record.status === "cancelled") return;
114
134
  record.status = res.success ? "completed" : "failed";
115
135
  record.success = res.success;
136
+ record.resumable = true;
116
137
  record.result = truncateDetachedResult(res.output || res.error || "");
117
138
  } catch (err) {
139
+ // A mutating runner may throw after a partial workspace change; retain
140
+ // conservative evidence before cancellation can hide the late failure.
141
+ if (!entry.readOnly) record.mutationMayHaveOccurred = true;
118
142
  if (record.status === "cancelled") return;
119
143
  record.status = "failed";
144
+ record.resumable = true;
120
145
  record.result = truncateDetachedResult(err instanceof Error ? err.message : String(err));
121
146
  } finally {
147
+ entry.settled = true;
122
148
  if (record.finishedAt === undefined) record.finishedAt = Date.now();
123
149
  this.steerInboxes.delete(id);
124
150
  }
151
+
125
152
  })();
126
- this.entries.set(id, { record, promise, abort });
127
153
  return record;
128
154
  }
129
155
 
@@ -139,6 +165,11 @@ export class SubagentRegistry {
139
165
  return this.list().filter(r => r.status === "running");
140
166
  }
141
167
 
168
+ /** Return mutating runs whose runner has not settled, including cancelled runs. */
169
+ inFlightMutators(): SubagentRecord[] {
170
+ return [...this.entries.values()].filter(entry => !entry.settled && !entry.readOnly).map(entry => entry.record);
171
+ }
172
+
142
173
  /** Wait for the given ids (or all running, when empty). With `timeoutMs` the wait
143
174
  * is bounded — unfinished runs simply stay "running" in the returned snapshot. */
144
175
  async awaitIds(ids: string[], timeoutMs?: number): Promise<SubagentRecord[]> {
@@ -184,16 +215,67 @@ export class SubagentRegistry {
184
215
  };
185
216
  }
186
217
 
218
+
219
+ /** Record an active step or tool description for a subagent id. */
220
+ setActivity(id: string, activity: string | undefined): void {
221
+ const rec = this.get(id);
222
+ if (rec) {
223
+ rec.activity = activity;
224
+ }
225
+ }
226
+
227
+ /** Request that one or more running subagents pause at their next safe boundary. */
228
+ pause(ids: string[], directive = DEFAULT_PAUSE_DIRECTIVE): SubagentRecord[] {
229
+ const out: SubagentRecord[] = [];
230
+ const targets = ids.length ? ids : this.running().map(r => r.id);
231
+ for (const id of targets) {
232
+ const rec = this.get(id);
233
+ if (!rec) continue;
234
+ if (rec.status === "running") {
235
+ this.steer(id, directive);
236
+ this.pausedIds.add(id);
237
+ rec.status = "paused";
238
+ rec.resumable = true;
239
+ }
240
+ out.push(rec);
241
+
242
+ }
243
+ return out;
244
+ }
245
+
246
+ /** Resume a paused (or resumable completed) subagent with follow-up instructions. */
247
+ resume(id: string, message = DEFAULT_RESUME_MESSAGE): { success: boolean; record?: SubagentRecord; error?: string } {
248
+ const rec = this.get(id);
249
+ if (!rec) return { success: false, error: `No subagent matches ${id}.` };
250
+ this.pausedIds.delete(id);
251
+ if (rec.status === "paused") {
252
+ rec.status = "running";
253
+ this.steer(id, message);
254
+ return { success: true, record: rec };
255
+ }
256
+ if (rec.status === "running") {
257
+ this.steer(id, message);
258
+ return { success: true, record: rec };
259
+ }
260
+ // If completed or failed, steer queues message for any attached listener
261
+ rec.status = "running";
262
+ rec.resumable = true;
263
+ this.steer(id, message);
264
+ return { success: true, record: rec };
265
+ }
266
+
187
267
  /** Cancel the given ids (or all running, when empty): aborts the run and marks the
188
268
  * record cancelled. Already-terminal records are returned unchanged. */
189
269
  cancel(ids: string[]): SubagentRecord[] {
190
270
  const out: SubagentRecord[] = [];
191
271
  for (const id of ids) {
272
+ this.pausedIds.delete(id);
192
273
  const e = this.entries.get(id);
193
274
  if (!e) continue;
194
- if (e.record.status === "running") {
275
+ if (e.record.status === "running" || e.record.status === "paused") {
195
276
  e.record.status = "cancelled";
196
277
  e.record.finishedAt = Date.now();
278
+ e.record.resumable = false;
197
279
  e.abort.abort();
198
280
  }
199
281
  out.push(e.record);
@@ -13,12 +13,31 @@ import type { ToolHandler } from "./engine";
13
13
  import type { ToolResult } from "./tools";
14
14
  import type { SubagentRegistry, SubagentRecord } from "./subagent-registry";
15
15
 
16
+ function mutationEvidence(recs: readonly SubagentRecord[]): boolean | undefined {
17
+ return recs.some(rec => rec.mutationMayHaveOccurred === true) ? true : undefined;
18
+ }
19
+
20
+ export type SubagentVerbosity = "receipt" | "preview" | "full";
21
+
22
+ const DEFAULT_VERBOSITY: SubagentVerbosity = "preview";
23
+ const DEFAULT_LIST_LIMIT = 10;
24
+ const MAX_LIST_LIMIT = 50;
25
+ const PREVIEW_CHARS = 2_000;
26
+ const FULL_CHARS = 12_000;
27
+
28
+
16
29
  /** One-line protocol description appended to the launch system prompt. */
17
30
  export const SUBAGENT_TOOL_PROTOCOL_LINE =
18
- `subagent {action:"list"|"inspect"|"await"|"cancel"|"steer", ids?, id?, message?, timeoutMs?} — control DETACHED ` +
19
- `subagents started with task{detached:true}. 'await' blocks (optionally up to timeoutMs ms) and ` +
20
- `returns their reports; 'inspect' shows status + result; 'cancel' aborts them; 'steer' sends {id, message} ` +
21
- `to inject a live instruction into that ONE running subagent, picked up before its next step. Omit ids to target all running (list/inspect/await/cancel).`;
31
+ `subagent {action:"list"|"inspect"|"await"|"cancel"|"steer"|"pause"|"resume", ids?, id?, message?, verbosity?:"receipt"|"preview"|"full", limit?:number, timeoutMs?} — control DETACHED ` +
32
+ `subagents started with task{detached:true}. 'await' blocks and returns reports; 'inspect' shows status + result; 'cancel' aborts; 'steer' injects live instructions into running subagents; ` +
33
+ `'pause' asks running subagents to wrap up safely; 'resume' wakes or redirects a paused/completed subagent. ` +
34
+ `'verbosity' controls result length ('receipt' status-only, 'preview' <=2000 chars, 'full' <=12000 chars); 'limit' caps listings.`;
35
+
36
+ const SAFE_ID_RE = /^[a-zA-Z0-9_-]+$/;
37
+
38
+ function isValidId(id: string): boolean {
39
+ return SAFE_ID_RE.test(id);
40
+ }
22
41
 
23
42
  function elapsed(rec: SubagentRecord): string {
24
43
  const end = rec.finishedAt ?? Date.now();
@@ -26,44 +45,93 @@ function elapsed(rec: SubagentRecord): string {
26
45
  }
27
46
 
28
47
  function rowLine(rec: SubagentRecord): string {
29
- return `- ${rec.id} [${rec.status.toUpperCase()}] ${elapsed(rec)} · ${rec.task}`;
48
+ const parts = [`- ${rec.id} [${rec.status.toUpperCase()}] ${elapsed(rec)} · ${rec.task}`];
49
+ if (rec.activity) parts.push(`(${rec.activity})`);
50
+ return parts.join(" ");
30
51
  }
31
52
 
32
- function detailBlock(rec: SubagentRecord): string {
53
+ function detailBlock(rec: SubagentRecord, verbosity?: SubagentVerbosity): string {
33
54
  const head = rowLine(rec);
34
- if (rec.status === "running" || !rec.result) return head;
35
- return `${head}\n${rec.result}`;
55
+ if (rec.status === "running" && !rec.result) return head;
56
+ if (verbosity === "receipt") return head;
57
+ const rawResult = rec.result ?? "";
58
+ if (verbosity === "preview" && rawResult.length > PREVIEW_CHARS) {
59
+ const text = `${rawResult.slice(0, PREVIEW_CHARS - 1)}… [truncated, use verbosity:"full"]`;
60
+ return `${head}\n${text}`;
61
+ }
62
+ return rawResult ? `${head}\n${rawResult}` : head;
36
63
  }
37
64
 
65
+
38
66
  function idsOf(args: Record<string, any>): string[] {
39
- if (Array.isArray(args.ids)) return args.ids.map((x: unknown) => String(x));
40
- if (args.id !== undefined) return [String(args.id)];
67
+ if (Array.isArray(args.ids)) return args.ids.map((x: unknown) => String(x)).filter(isValidId);
68
+ if (args.id !== undefined) {
69
+ const s = String(args.id);
70
+ return isValidId(s) ? [s] : [];
71
+ }
41
72
  return [];
42
73
  }
43
74
 
75
+ function clampLimit(value: unknown): number {
76
+ if (typeof value !== "number" || Number.isNaN(value)) return DEFAULT_LIST_LIMIT;
77
+ return Math.max(1, Math.min(MAX_LIST_LIMIT, Math.floor(value)));
78
+ }
79
+
44
80
  export function createSubagentTool(registry: SubagentRegistry): ToolHandler {
45
81
  return async (args: Record<string, any>, _cwd: string): Promise<ToolResult> => {
46
82
  const action = String(args.action ?? "list").trim().toLowerCase();
47
83
  const ids = idsOf(args);
84
+ const verbosity: SubagentVerbosity | undefined = ["receipt", "preview", "full"].includes(args.verbosity) ? args.verbosity : undefined;
85
+
86
+ if (verbosity === "full" && action === "list") {
87
+ return {
88
+ success: false,
89
+ output: "",
90
+ error: "verbosity='full' is not permitted with action='list' to protect context budget. Use action='inspect' with explicit id(s).",
91
+ };
92
+ }
93
+
48
94
 
49
95
  if (action === "list") {
50
96
  const rows = registry.list();
51
97
  if (rows.length === 0) {
52
98
  return { success: true, output: "No detached subagents in this session. Launch one with task {detached:true}." };
53
99
  }
100
+ const limit = clampLimit(args.limit);
101
+ const sorted = [...rows].reverse(); // newest first
102
+ const visible = sorted.slice(0, limit);
54
103
  const running = rows.filter(r => r.status === "running").length;
55
- return { success: true, output: `${rows.length} subagent(s), ${running} running:\n${rows.map(rowLine).join("\n")}` };
104
+ const paused = rows.filter(r => r.status === "paused").length;
105
+ const truncatedMsg = rows.length > limit ? `\n(… ${rows.length - limit} older subagent(s) omitted; specify limit or explicit ids)` : "";
106
+ return {
107
+ success: true,
108
+ output: `${rows.length} subagent(s) (${running} running, ${paused} paused):\n${visible.map(rowLine).join("\n")}${truncatedMsg}`,
109
+ mutationMayHaveOccurred: mutationEvidence(rows),
110
+ };
111
+
56
112
  }
57
113
 
58
114
  if (action === "inspect") {
59
115
  const targets = (ids.length ? ids.map(id => registry.get(id)) : registry.list())
60
116
  .filter((r): r is SubagentRecord => r !== undefined);
61
117
  if (targets.length === 0) {
62
- return { success: false, output: "", error: ids.length ? `No subagent matches ${ids.join(", ")}.` : "No detached subagents in this session." };
118
+ return {
119
+ success: false,
120
+ output: "",
121
+ error: ids.length ? `No subagent matches ${ids.join(", ")}.` : "No detached subagents in this session.",
122
+ };
63
123
  }
64
- return { success: true, output: targets.map(detailBlock).join("\n\n") };
124
+ const limit = ids.length ? targets.length : clampLimit(args.limit);
125
+ const visible = targets.slice(0, limit);
126
+
127
+ return {
128
+ success: true,
129
+ output: visible.map(r => detailBlock(r, verbosity)).join("\n\n"),
130
+ mutationMayHaveOccurred: mutationEvidence(targets),
131
+ };
65
132
  }
66
133
 
134
+
67
135
  if (action === "await") {
68
136
  const targets = ids.length ? ids : registry.running().map(r => r.id);
69
137
  if (targets.length === 0) {
@@ -73,9 +141,41 @@ export function createSubagentTool(registry: SubagentRegistry): ToolHandler {
73
141
  const recs = await registry.awaitIds(targets, timeoutMs);
74
142
  const stillRunning = recs.filter(r => r.status === "running").length;
75
143
  const head = stillRunning > 0
76
- ? `Awaited ${recs.length} subagent(s); ${stillRunning} still running after the ${timeoutMs}ms timeout — await again or cancel.`
144
+ ? `Awaited ${recs.length} subagent(s); ${stillRunning} still running after the ${timeoutMs}ms timeout — await again, pause, or cancel.`
77
145
  : `Awaited ${recs.length} subagent(s); all settled.`;
78
- return { success: stillRunning === 0, output: `${head}\n\n${recs.map(detailBlock).join("\n\n")}` };
146
+ return {
147
+ success: stillRunning === 0,
148
+ output: `${head}\n\n${recs.map(r => detailBlock(r, verbosity)).join("\n\n")}`,
149
+ mutationMayHaveOccurred: mutationEvidence(recs),
150
+ };
151
+ }
152
+
153
+
154
+ if (action === "pause") {
155
+ const targets = ids.length ? ids : registry.running().map(r => r.id);
156
+ if (targets.length === 0) {
157
+ return { success: true, output: "No running subagents to pause." };
158
+ }
159
+ const message = typeof args.message === "string" && args.message.trim() ? args.message.trim() : undefined;
160
+ const paused = registry.pause(targets, message);
161
+ return { success: true, output: `Paused ${paused.length} subagent(s):\n${paused.map(rowLine).join("\n")}` };
162
+ }
163
+
164
+ if (action === "resume") {
165
+ if (ids.length !== 1) {
166
+ return {
167
+ success: false,
168
+ output: "",
169
+ error: `subagent 'resume' requires exactly one target id (e.g. {action:"resume", id:"executor-1"}). Got: ${JSON.stringify(args.id || args.ids)}.`,
170
+ };
171
+ }
172
+ const id = ids[0]!;
173
+ const message = typeof args.message === "string" && args.message.trim() ? args.message.trim() : undefined;
174
+ const res = registry.resume(id, message);
175
+ if (!res.success) {
176
+ return { success: false, output: "", error: res.error || `Failed to resume subagent '${id}'.` };
177
+ }
178
+ return { success: true, output: `Resumed subagent '${id}' (${res.record?.status}): ${message ?? "resumed with default message"}` };
79
179
  }
80
180
 
81
181
  if (action === "cancel") {
@@ -84,16 +184,27 @@ export function createSubagentTool(registry: SubagentRegistry): ToolHandler {
84
184
  return { success: true, output: "No running subagents to cancel." };
85
185
  }
86
186
  const recs = registry.cancel(targets);
87
- return { success: true, output: `Cancelled ${recs.length} subagent(s):\n${recs.map(rowLine).join("\n")}` };
187
+ return {
188
+ success: true,
189
+ output: `Cancelled ${recs.length} subagent(s):\n${recs.map(rowLine).join("\n")}`,
190
+ mutationMayHaveOccurred: mutationEvidence(recs),
191
+ };
88
192
  }
89
193
 
90
194
  if (action === "steer") {
91
195
  const targetIds = Array.isArray(args.id) ? [] : args.id !== undefined ? [String(args.id)] : [];
92
196
  const message = typeof args.message === "string" ? args.message : typeof args.text === "string" ? args.text : "";
93
197
  if (targetIds.length !== 1) {
94
- return { success: false, output: "", error: `subagent 'steer' requires exactly one target 'id' (a single string, not 'ids'). Got: ${JSON.stringify(args.id)}.` };
198
+ return {
199
+ success: false,
200
+ output: "",
201
+ error: `subagent 'steer' requires exactly one target 'id' (a single string, not 'ids'). Got: ${JSON.stringify(args.id)}.`,
202
+ };
95
203
  }
96
204
  const id = targetIds[0]!;
205
+ if (!isValidId(id)) {
206
+ return { success: false, output: "", error: `Invalid subagent id '${id}'.` };
207
+ }
97
208
  if (!message.trim()) {
98
209
  return { success: false, output: "", error: "subagent 'steer' requires a non-empty 'message' (or 'text')." };
99
210
  }
@@ -104,6 +215,10 @@ export function createSubagentTool(registry: SubagentRegistry): ToolHandler {
104
215
  return { success: true, output: `Steered ${id}: ${message.trim()}` };
105
216
  }
106
217
 
107
- return { success: false, output: "", error: `Unknown subagent action '${action}'. Use list | inspect | await | cancel | steer.` };
218
+ return {
219
+ success: false,
220
+ output: "",
221
+ error: `Unknown subagent action '${action}'. Use list | inspect | await | cancel | steer | pause | resume.`,
222
+ };
108
223
  };
109
224
  }