@narumitw/pi-subagents 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +137 -12
- package/package.json +3 -3
- package/src/agents.ts +17 -0
- package/src/config-ui.ts +271 -0
- package/src/context.ts +129 -0
- package/src/execution.ts +453 -0
- package/src/in-process-transport.ts +598 -0
- package/src/limits.ts +62 -0
- package/src/orchestration.ts +164 -0
- package/src/params.ts +59 -0
- package/src/persistence.ts +207 -0
- package/src/protocol.ts +76 -0
- package/src/registry.ts +759 -0
- package/src/render.ts +521 -0
- package/src/runner.ts +496 -0
- package/src/settings.ts +166 -0
- package/src/stateful-prompt.ts +43 -0
- package/src/stateful.ts +838 -0
- package/src/subagents.ts +36 -1682
- package/src/subprocess-transport.ts +50 -0
- package/src/transport.ts +30 -0
- package/src/workspace.ts +116 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export type OrchestrationRecoveryTicket = {
|
|
4
|
+
generation: number;
|
|
5
|
+
revision: number;
|
|
6
|
+
nonce: string;
|
|
7
|
+
prompt: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
type DelegatedAgent = {
|
|
11
|
+
live: boolean;
|
|
12
|
+
resultAvailable: boolean;
|
|
13
|
+
observedInTurn: boolean;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const ORCHESTRATION_MARKER_PREFIX = "pi-subagent-orchestration:";
|
|
17
|
+
|
|
18
|
+
const RECOVERY_PROMPT = [
|
|
19
|
+
"Delegated subagents still need root coordination.",
|
|
20
|
+
"Do not yield permanently while current delegated work is unresolved.",
|
|
21
|
+
"Continue useful non-overlapping local work, or call subagent_wait when no useful local continuation remains.",
|
|
22
|
+
"Consume available subagent results and synthesize them before finishing.",
|
|
23
|
+
"Interrupt or close agents that are no longer needed; do not wait forever or spawn more agents unnecessarily.",
|
|
24
|
+
].join(" ");
|
|
25
|
+
|
|
26
|
+
/** Ephemeral root-turn coordination state. Never persisted across sessions. */
|
|
27
|
+
export class RootOrchestrationState {
|
|
28
|
+
private generation = 0;
|
|
29
|
+
private revision = 0;
|
|
30
|
+
private turnOpen = false;
|
|
31
|
+
private agents = new Map<string, DelegatedAgent>();
|
|
32
|
+
private pending: OrchestrationRecoveryTicket | undefined;
|
|
33
|
+
private deliveredRevision: number | undefined;
|
|
34
|
+
|
|
35
|
+
reset(): void {
|
|
36
|
+
this.generation += 1;
|
|
37
|
+
this.revision = 0;
|
|
38
|
+
this.turnOpen = false;
|
|
39
|
+
this.agents.clear();
|
|
40
|
+
this.pending = undefined;
|
|
41
|
+
this.deliveredRevision = undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
beginTurn(): void {
|
|
45
|
+
this.turnOpen = true;
|
|
46
|
+
if (this.pending) this.deliveredRevision = this.pending.revision;
|
|
47
|
+
this.observeAvailable();
|
|
48
|
+
// New root work supersedes an intent that has not been delivered yet. The
|
|
49
|
+
// turn gets a chance to coordinate before agent_end evaluates it again.
|
|
50
|
+
this.pending = undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
spawn(agentId: string): void {
|
|
54
|
+
this.agents.set(agentId, {
|
|
55
|
+
live: true,
|
|
56
|
+
resultAvailable: false,
|
|
57
|
+
observedInTurn: false,
|
|
58
|
+
});
|
|
59
|
+
this.bumpRevision();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
complete(agentId: string): void {
|
|
63
|
+
const existing = this.agents.get(agentId);
|
|
64
|
+
if (!existing) return;
|
|
65
|
+
this.agents.set(agentId, {
|
|
66
|
+
live: false,
|
|
67
|
+
resultAvailable: true,
|
|
68
|
+
observedInTurn: false,
|
|
69
|
+
});
|
|
70
|
+
this.bumpRevision();
|
|
71
|
+
if (!this.turnOpen) this.ensureRecovery();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
resolve(agentId: string): void {
|
|
75
|
+
if (!this.agents.delete(agentId)) return;
|
|
76
|
+
this.bumpRevision();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
observe(agentId: string): void {
|
|
80
|
+
const agent = this.agents.get(agentId);
|
|
81
|
+
if (agent && !agent.live && agent.resultAvailable) agent.observedInTurn = true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
observeAvailable(): void {
|
|
85
|
+
for (const agent of this.agents.values()) {
|
|
86
|
+
if (!agent.live && agent.resultAvailable) agent.observedInTurn = true;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
endTurn(): OrchestrationRecoveryTicket | undefined {
|
|
91
|
+
if (this.turnOpen) {
|
|
92
|
+
for (const [id, agent] of this.agents) {
|
|
93
|
+
if (!agent.live && agent.resultAvailable && agent.observedInTurn) {
|
|
94
|
+
this.agents.delete(id);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
this.turnOpen = false;
|
|
98
|
+
}
|
|
99
|
+
return this.ensureRecovery();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
pendingTicket(): OrchestrationRecoveryTicket | undefined {
|
|
103
|
+
return this.pending;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
isCurrent(ticket: OrchestrationRecoveryTicket): boolean {
|
|
107
|
+
return (
|
|
108
|
+
ticket.generation === this.generation &&
|
|
109
|
+
ticket.revision === this.revision &&
|
|
110
|
+
ticket.nonce === this.pending?.nonce &&
|
|
111
|
+
this.hasUnresolved()
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
markDelivered(ticket: OrchestrationRecoveryTicket): void {
|
|
116
|
+
if (!this.isCurrent(ticket)) return;
|
|
117
|
+
this.pending = undefined;
|
|
118
|
+
this.deliveredRevision = ticket.revision;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
supersedePending(): OrchestrationRecoveryTicket | undefined {
|
|
122
|
+
const ticket = this.pending;
|
|
123
|
+
if (!ticket) return undefined;
|
|
124
|
+
this.pending = undefined;
|
|
125
|
+
this.deliveredRevision = ticket.revision;
|
|
126
|
+
return ticket;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
hasUnresolved(): boolean {
|
|
130
|
+
return this.agents.size > 0;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
liveAgentIds(): string[] {
|
|
134
|
+
return [...this.agents]
|
|
135
|
+
.filter(([, agent]) => agent.live)
|
|
136
|
+
.map(([id]) => id)
|
|
137
|
+
.sort();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private ensureRecovery(): OrchestrationRecoveryTicket | undefined {
|
|
141
|
+
if (!this.hasUnresolved()) {
|
|
142
|
+
this.pending = undefined;
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
if (this.deliveredRevision === this.revision) return undefined;
|
|
146
|
+
if (this.pending?.revision === this.revision && this.pending.generation === this.generation) {
|
|
147
|
+
return this.pending;
|
|
148
|
+
}
|
|
149
|
+
const nonce = randomUUID();
|
|
150
|
+
this.pending = {
|
|
151
|
+
generation: this.generation,
|
|
152
|
+
revision: this.revision,
|
|
153
|
+
nonce,
|
|
154
|
+
prompt: `${RECOVERY_PROMPT}\n<!-- ${ORCHESTRATION_MARKER_PREFIX}${nonce} -->`,
|
|
155
|
+
};
|
|
156
|
+
return this.pending;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private bumpRevision(): void {
|
|
160
|
+
this.revision += 1;
|
|
161
|
+
this.pending = undefined;
|
|
162
|
+
this.deliveredRevision = undefined;
|
|
163
|
+
}
|
|
164
|
+
}
|
package/src/params.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
|
+
import { Type, type Static } from "typebox";
|
|
3
|
+
import { THINKING_LEVELS } from "./agents.js";
|
|
4
|
+
|
|
5
|
+
const TimeoutMs = Type.Number({
|
|
6
|
+
description:
|
|
7
|
+
"Hard timeout in milliseconds for each subagent subprocess. Defaults to PI_SUBAGENT_TIMEOUT_MS or 600000.",
|
|
8
|
+
minimum: 1,
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
const ThinkingLevelSchema = StringEnum(THINKING_LEVELS, {
|
|
12
|
+
description: "Pi thinking level for the subagent process: off, minimal, low, medium, high, or xhigh.",
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const TaskItem = Type.Object({
|
|
16
|
+
agent: Type.String({ description: "Name of the agent to invoke" }),
|
|
17
|
+
task: Type.String({ description: "Task to delegate to the agent" }),
|
|
18
|
+
cwd: Type.Optional(Type.String({ description: "Working directory for the agent process" })),
|
|
19
|
+
timeoutMs: Type.Optional(TimeoutMs),
|
|
20
|
+
thinkingLevel: Type.Optional(ThinkingLevelSchema),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const ChainItem = Type.Object({
|
|
24
|
+
agent: Type.String({ description: "Name of the agent to invoke" }),
|
|
25
|
+
task: Type.String({ description: "Task with optional {previous} placeholder for prior output" }),
|
|
26
|
+
cwd: Type.Optional(Type.String({ description: "Working directory for the agent process" })),
|
|
27
|
+
timeoutMs: Type.Optional(TimeoutMs),
|
|
28
|
+
thinkingLevel: Type.Optional(ThinkingLevelSchema),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const AggregatorItem = Type.Object({
|
|
32
|
+
agent: Type.String({ description: "Name of the fan-in agent to invoke after parallel tasks complete" }),
|
|
33
|
+
task: Type.String({ description: "Fan-in task. Use {previous} to include all parallel outputs." }),
|
|
34
|
+
cwd: Type.Optional(Type.String({ description: "Working directory for the aggregator process" })),
|
|
35
|
+
timeoutMs: Type.Optional(TimeoutMs),
|
|
36
|
+
thinkingLevel: Type.Optional(ThinkingLevelSchema),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const AgentScopeSchema = StringEnum(["user", "project", "both"] as const, {
|
|
40
|
+
description: 'Which agent directories to use. Default: "user". Use "both" to include project-local agents.',
|
|
41
|
+
default: "user",
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export const SubagentParams = Type.Object({
|
|
45
|
+
agent: Type.Optional(Type.String({ description: "Name of the agent to invoke (for single mode)" })),
|
|
46
|
+
task: Type.Optional(Type.String({ description: "Task to delegate (for single mode)" })),
|
|
47
|
+
tasks: Type.Optional(Type.Array(TaskItem, { description: "Array of {agent, task} for parallel execution" })),
|
|
48
|
+
chain: Type.Optional(Type.Array(ChainItem, { description: "Array of {agent, task} for sequential execution" })),
|
|
49
|
+
aggregator: Type.Optional(AggregatorItem),
|
|
50
|
+
agentScope: Type.Optional(AgentScopeSchema),
|
|
51
|
+
confirmProjectAgents: Type.Optional(
|
|
52
|
+
Type.Boolean({ description: "Prompt before running project-local agents. Default: true.", default: true }),
|
|
53
|
+
),
|
|
54
|
+
cwd: Type.Optional(Type.String({ description: "Working directory for the agent process (single mode)" })),
|
|
55
|
+
timeoutMs: Type.Optional(TimeoutMs),
|
|
56
|
+
thinkingLevel: Type.Optional(ThinkingLevelSchema),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
export type SubagentParams = Static<typeof SubagentParams>;
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { getAgentDir, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { redactPrivateText } from "./context.js";
|
|
6
|
+
import type { ManagedAgent } from "./registry.js";
|
|
7
|
+
|
|
8
|
+
const STATE_VERSION = 2;
|
|
9
|
+
const MAX_STATE_BYTES = 1024 * 1024;
|
|
10
|
+
|
|
11
|
+
interface StoredState {
|
|
12
|
+
version: 2;
|
|
13
|
+
updatedAt: number;
|
|
14
|
+
agents: ManagedAgent[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface PersistenceOptions {
|
|
18
|
+
retentionDays?: number;
|
|
19
|
+
maxStoredAgents?: number;
|
|
20
|
+
stateDir?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class AgentPersistence {
|
|
24
|
+
readonly filePath: string;
|
|
25
|
+
private readonly retentionMs: number;
|
|
26
|
+
private readonly maxStoredAgents: number;
|
|
27
|
+
|
|
28
|
+
constructor(owner: string, options: PersistenceOptions = {}) {
|
|
29
|
+
const retentionDays = options.retentionDays ?? 30;
|
|
30
|
+
if (!Number.isFinite(retentionDays) || retentionDays <= 0) {
|
|
31
|
+
throw new Error("Subagent retentionDays must be a positive finite number");
|
|
32
|
+
}
|
|
33
|
+
const retentionMs = retentionDays * 24 * 60 * 60 * 1000;
|
|
34
|
+
if (!Number.isFinite(retentionMs)) {
|
|
35
|
+
throw new Error("Subagent retentionDays is too large");
|
|
36
|
+
}
|
|
37
|
+
const maxStoredAgents = options.maxStoredAgents ?? 50;
|
|
38
|
+
if (!Number.isSafeInteger(maxStoredAgents) || maxStoredAgents < 1) {
|
|
39
|
+
throw new Error("Subagent maxStoredAgents must be a positive safe integer");
|
|
40
|
+
}
|
|
41
|
+
const safeOwner = createHash("sha256").update(owner).digest("hex").slice(0, 24);
|
|
42
|
+
const stateDir = options.stateDir ?? path.join(getAgentDir(), "pi-subagents-state");
|
|
43
|
+
this.filePath = path.join(stateDir, `${safeOwner}.json`);
|
|
44
|
+
this.retentionMs = retentionMs;
|
|
45
|
+
this.maxStoredAgents = maxStoredAgents;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
load(): ManagedAgent[] {
|
|
49
|
+
if (!fs.existsSync(this.filePath)) return [];
|
|
50
|
+
try {
|
|
51
|
+
const stat = fs.statSync(this.filePath);
|
|
52
|
+
if (stat.size > MAX_STATE_BYTES) throw new Error("state exceeds size limit");
|
|
53
|
+
const parsed = JSON.parse(fs.readFileSync(this.filePath, "utf8")) as unknown;
|
|
54
|
+
if (!isStoredState(parsed)) throw new Error("unsupported or malformed state");
|
|
55
|
+
const cutoff = Date.now() - this.retentionMs;
|
|
56
|
+
return parsed.agents
|
|
57
|
+
.filter((agent) => agent.updatedAt >= cutoff && agent.state !== "closed")
|
|
58
|
+
.slice(-this.maxStoredAgents)
|
|
59
|
+
.map(sanitizeAgent);
|
|
60
|
+
} catch {
|
|
61
|
+
this.quarantine();
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async save(agents: readonly ManagedAgent[]): Promise<void> {
|
|
67
|
+
const cutoff = Date.now() - this.retentionMs;
|
|
68
|
+
const eligible = agents.filter(
|
|
69
|
+
(agent) => agent.state !== "closed" && agent.updatedAt >= cutoff,
|
|
70
|
+
);
|
|
71
|
+
const records = selectAgentsForPersistence(eligible, this.maxStoredAgents).map(sanitizeAgent);
|
|
72
|
+
const state: StoredState = { version: STATE_VERSION, updatedAt: Date.now(), agents: records };
|
|
73
|
+
let content = `${JSON.stringify(state, null, "\t")}\n`;
|
|
74
|
+
while (Buffer.byteLength(content, "utf8") > MAX_STATE_BYTES && state.agents.length > 0) {
|
|
75
|
+
const oldestRootId = state.agents[0].rootId;
|
|
76
|
+
state.agents = state.agents.filter((agent) => agent.rootId !== oldestRootId);
|
|
77
|
+
content = `${JSON.stringify(state, null, "\t")}\n`;
|
|
78
|
+
}
|
|
79
|
+
await fs.promises.mkdir(path.dirname(this.filePath), { recursive: true });
|
|
80
|
+
await withFileMutationQueue(this.filePath, async () => {
|
|
81
|
+
const tempPath = `${this.filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
82
|
+
await fs.promises.writeFile(tempPath, content, { encoding: "utf8", mode: 0o600 });
|
|
83
|
+
await fs.promises.rename(tempPath, this.filePath);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async delete(): Promise<void> {
|
|
88
|
+
await withFileMutationQueue(this.filePath, async () => {
|
|
89
|
+
await fs.promises.rm(this.filePath, { force: true });
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private quarantine(): void {
|
|
94
|
+
try {
|
|
95
|
+
fs.renameSync(this.filePath, `${this.filePath}.invalid-${Date.now()}`);
|
|
96
|
+
} catch {
|
|
97
|
+
// A concurrent process may already have moved or removed it.
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function selectAgentsForPersistence(
|
|
103
|
+
agents: readonly ManagedAgent[],
|
|
104
|
+
maxAgents: number,
|
|
105
|
+
): ManagedAgent[] {
|
|
106
|
+
const byId = new Map(agents.map((agent) => [agent.id, agent]));
|
|
107
|
+
const selected = new Map<string, ManagedAgent>();
|
|
108
|
+
const newestFirst = [...agents].sort((left, right) => right.updatedAt - left.updatedAt);
|
|
109
|
+
for (const agent of newestFirst) {
|
|
110
|
+
const chain: ManagedAgent[] = [];
|
|
111
|
+
let current: ManagedAgent | undefined = agent;
|
|
112
|
+
const seen = new Set<string>();
|
|
113
|
+
while (current && !seen.has(current.id)) {
|
|
114
|
+
seen.add(current.id);
|
|
115
|
+
chain.unshift(current);
|
|
116
|
+
current = current.parentId ? byId.get(current.parentId) : undefined;
|
|
117
|
+
}
|
|
118
|
+
if (current || (agent.parentId && chain[0].parentId)) continue;
|
|
119
|
+
const missing = chain.filter((candidate) => !selected.has(candidate.id));
|
|
120
|
+
if (selected.size + missing.length > maxAgents) continue;
|
|
121
|
+
for (const candidate of missing) selected.set(candidate.id, candidate);
|
|
122
|
+
}
|
|
123
|
+
return agents.filter((agent) => selected.has(agent.id));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function sanitizeAgent(agent: ManagedAgent): ManagedAgent {
|
|
127
|
+
return {
|
|
128
|
+
...agent,
|
|
129
|
+
rootId: agent.rootId ?? agent.id,
|
|
130
|
+
depth: agent.depth ?? 0,
|
|
131
|
+
children: [...(agent.children ?? [])],
|
|
132
|
+
mailbox: (agent.mailbox ?? []).map((message) => ({
|
|
133
|
+
...message,
|
|
134
|
+
recipientId: agent.id,
|
|
135
|
+
content: redactPrivateText(message.content),
|
|
136
|
+
})),
|
|
137
|
+
state: "idle",
|
|
138
|
+
currentTask: undefined,
|
|
139
|
+
currentMailboxMessageIds: undefined,
|
|
140
|
+
context: agent.context ? redactPrivateText(agent.context) : undefined,
|
|
141
|
+
error: agent.error ? redactPrivateText(agent.error) : undefined,
|
|
142
|
+
history: agent.history.map((turn) => ({
|
|
143
|
+
...turn,
|
|
144
|
+
task: redactPrivateText(turn.task),
|
|
145
|
+
output: redactPrivateText(turn.output),
|
|
146
|
+
})),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function isStoredState(value: unknown): value is StoredState {
|
|
151
|
+
if (!value || typeof value !== "object") return false;
|
|
152
|
+
const state = value as { version?: unknown; agents?: unknown };
|
|
153
|
+
if ((state.version !== 1 && state.version !== STATE_VERSION) || !Array.isArray(state.agents)) {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
return state.agents.every((agent) => {
|
|
157
|
+
if (!agent || typeof agent !== "object") return false;
|
|
158
|
+
const record = agent as Partial<ManagedAgent>;
|
|
159
|
+
return (
|
|
160
|
+
typeof record.id === "string" &&
|
|
161
|
+
typeof record.agent === "string" &&
|
|
162
|
+
typeof record.cwd === "string" &&
|
|
163
|
+
typeof record.createdAt === "number" &&
|
|
164
|
+
Number.isFinite(record.createdAt) &&
|
|
165
|
+
typeof record.updatedAt === "number" &&
|
|
166
|
+
Number.isFinite(record.updatedAt) &&
|
|
167
|
+
(record.parentId === undefined || typeof record.parentId === "string") &&
|
|
168
|
+
(record.children === undefined ||
|
|
169
|
+
(Array.isArray(record.children) && record.children.every((id) => typeof id === "string"))) &&
|
|
170
|
+
Array.isArray(record.history) &&
|
|
171
|
+
record.history.every(isAgentTurn) &&
|
|
172
|
+
(record.mailbox === undefined ||
|
|
173
|
+
(Array.isArray(record.mailbox) && record.mailbox.every(isMailboxMessage)))
|
|
174
|
+
);
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function isAgentTurn(value: unknown): boolean {
|
|
179
|
+
if (!value || typeof value !== "object") return false;
|
|
180
|
+
const turn = value as Record<string, unknown>;
|
|
181
|
+
return (
|
|
182
|
+
typeof turn.task === "string" &&
|
|
183
|
+
typeof turn.output === "string" &&
|
|
184
|
+
typeof turn.startedAt === "number" &&
|
|
185
|
+
Number.isFinite(turn.startedAt) &&
|
|
186
|
+
typeof turn.completedAt === "number" &&
|
|
187
|
+
Number.isFinite(turn.completedAt) &&
|
|
188
|
+
typeof turn.exitCode === "number" &&
|
|
189
|
+
Number.isFinite(turn.exitCode)
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function isMailboxMessage(value: unknown): boolean {
|
|
194
|
+
if (!value || typeof value !== "object") return false;
|
|
195
|
+
const message = value as Record<string, unknown>;
|
|
196
|
+
return (
|
|
197
|
+
typeof message.id === "string" &&
|
|
198
|
+
typeof message.senderId === "string" &&
|
|
199
|
+
typeof message.recipientId === "string" &&
|
|
200
|
+
typeof message.content === "string" &&
|
|
201
|
+
typeof message.createdAt === "number" &&
|
|
202
|
+
Number.isFinite(message.createdAt) &&
|
|
203
|
+
(message.readAt === undefined ||
|
|
204
|
+
(typeof message.readAt === "number" && Number.isFinite(message.readAt))) &&
|
|
205
|
+
(message.deduplicationKey === undefined || typeof message.deduplicationKey === "string")
|
|
206
|
+
);
|
|
207
|
+
}
|
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { StringDecoder } from "node:string_decoder";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_MAX_LINE_BYTES = 256 * 1024;
|
|
4
|
+
|
|
5
|
+
export interface JsonLineDecoderOptions {
|
|
6
|
+
maxLineBytes?: number;
|
|
7
|
+
onValue: (value: unknown) => void;
|
|
8
|
+
onMalformed?: (line: string) => void;
|
|
9
|
+
onOversized?: (bytes: number) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Bounded newline-delimited JSON decoder for child Pi event streams. */
|
|
13
|
+
export class JsonLineDecoder {
|
|
14
|
+
private buffer = "";
|
|
15
|
+
private droppingOversizedLine = false;
|
|
16
|
+
private readonly maxLineBytes: number;
|
|
17
|
+
private readonly decoder = new StringDecoder("utf8");
|
|
18
|
+
|
|
19
|
+
constructor(private readonly options: JsonLineDecoderOptions) {
|
|
20
|
+
const maxLineBytes = options.maxLineBytes ?? DEFAULT_MAX_LINE_BYTES;
|
|
21
|
+
if (!Number.isSafeInteger(maxLineBytes) || maxLineBytes < 1) {
|
|
22
|
+
throw new Error("JSON line limit must be a positive safe integer");
|
|
23
|
+
}
|
|
24
|
+
this.maxLineBytes = maxLineBytes;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
push(chunk: string | Buffer): void {
|
|
28
|
+
this.buffer += typeof chunk === "string" ? chunk : this.decoder.write(chunk);
|
|
29
|
+
this.drain(false);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
finish(): void {
|
|
33
|
+
this.buffer += this.decoder.end();
|
|
34
|
+
this.drain(true);
|
|
35
|
+
this.buffer = "";
|
|
36
|
+
this.droppingOversizedLine = false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private drain(flush: boolean): void {
|
|
40
|
+
while (true) {
|
|
41
|
+
const newline = this.buffer.indexOf("\n");
|
|
42
|
+
if (newline < 0) break;
|
|
43
|
+
const line = this.buffer.slice(0, newline).replace(/\r$/, "");
|
|
44
|
+
this.buffer = this.buffer.slice(newline + 1);
|
|
45
|
+
if (this.droppingOversizedLine) {
|
|
46
|
+
this.droppingOversizedLine = false;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
this.processLine(line);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!flush && Buffer.byteLength(this.buffer, "utf8") > this.maxLineBytes) {
|
|
53
|
+
this.options.onOversized?.(Buffer.byteLength(this.buffer, "utf8"));
|
|
54
|
+
this.buffer = "";
|
|
55
|
+
this.droppingOversizedLine = true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (flush && this.buffer.length > 0 && !this.droppingOversizedLine) {
|
|
59
|
+
this.processLine(this.buffer.replace(/\r$/, ""));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private processLine(line: string): void {
|
|
64
|
+
if (!line.trim()) return;
|
|
65
|
+
const bytes = Buffer.byteLength(line, "utf8");
|
|
66
|
+
if (bytes > this.maxLineBytes) {
|
|
67
|
+
this.options.onOversized?.(bytes);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
this.options.onValue(JSON.parse(line));
|
|
72
|
+
} catch {
|
|
73
|
+
this.options.onMalformed?.(line);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|