jeo-code 0.12.9 → 0.12.11
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.
- package/CHANGELOG.md +15 -0
- package/README.ja.md +17 -12
- package/README.ko.md +86 -27
- package/README.md +76 -20
- package/README.zh.md +17 -12
- package/package.json +2 -1
- package/src/agent/ast-edit-tool.ts +3 -1
- package/src/agent/engine.ts +114 -16
- package/src/agent/goal-tool.ts +7 -1
- package/src/agent/goal-verifier.ts +95 -16
- package/src/agent/loop-guards.ts +10 -9
- package/src/agent/lsp-rename-tool.ts +3 -1
- package/src/agent/notify/discord-daemon.ts +3 -1
- package/src/agent/notify/paths.ts +8 -0
- package/src/agent/notify/session-endpoint.ts +23 -4
- package/src/agent/notify/slack-daemon.ts +2 -2
- package/src/agent/notify/telegram-daemon.ts +223 -89
- package/src/agent/notify/topic-registry.ts +118 -15
- package/src/agent/skill-router.ts +31 -5
- package/src/agent/subagent-registry.ts +87 -5
- package/src/agent/subagent-tool.ts +133 -18
- package/src/agent/subagents.ts +7 -5
- package/src/agent/task-tool.ts +85 -14
- package/src/agent/tools.ts +14 -4
- package/src/cli.ts +1 -0
- package/src/commands/launch/flags.ts +2 -1
- package/src/commands/launch.ts +163 -16
- package/src/commands/messenger.ts +7 -1
- package/src/commands/notify.ts +5 -6
- package/src/commands/skills.ts +9 -0
- package/src/commands/team.ts +72 -53
- package/src/messenger/config.ts +8 -3
- package/src/messenger/discord-setup.ts +3 -1
- package/src/messenger/discord.ts +5 -1
- package/src/messenger/session-pool.ts +99 -13
- package/src/messenger/types.ts +2 -0
- 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
|
|
97
|
+
private topics: Map<string, TopicRecord>;
|
|
46
98
|
/** Maps topicId -> sessionId for fast inbound routing. */
|
|
47
|
-
private
|
|
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(
|
|
53
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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 } =
|
|
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
|
|
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
|
|
20
|
-
`
|
|
21
|
-
`
|
|
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
|
-
|
|
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"
|
|
35
|
-
return
|
|
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)
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
}
|