@yaag/runtime 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/package.json +25 -0
  2. package/src/agent-names.ts +20 -0
  3. package/src/agent-usage.ts +72 -0
  4. package/src/agent.ts +130 -0
  5. package/src/args-validation.ts +11 -0
  6. package/src/ask-activity.ts +84 -0
  7. package/src/ask-contract-identity.ts +96 -0
  8. package/src/ask-exchange-events.ts +60 -0
  9. package/src/ask-exchange-options.ts +32 -0
  10. package/src/ask-exchange.ts +291 -0
  11. package/src/ask-hash.ts +86 -0
  12. package/src/ask-limit.ts +189 -0
  13. package/src/ask-output-steering.ts +69 -0
  14. package/src/ask-output-tail.ts +166 -0
  15. package/src/ask-output.ts +109 -0
  16. package/src/ask-settlement.ts +37 -0
  17. package/src/ask-turn.ts +70 -0
  18. package/src/cassette-loader.ts +131 -0
  19. package/src/cassette-publish.ts +55 -0
  20. package/src/cassette-replay.ts +178 -0
  21. package/src/cassette-schema.ts +152 -0
  22. package/src/cassette.ts +275 -0
  23. package/src/checkpoint-dir.ts +89 -0
  24. package/src/connection.ts +123 -0
  25. package/src/define-agent.ts +83 -0
  26. package/src/define-run.ts +69 -0
  27. package/src/errors.ts +115 -0
  28. package/src/events.ts +143 -0
  29. package/src/extension-package.ts +88 -0
  30. package/src/extension-paths.ts +66 -0
  31. package/src/extension-source.ts +60 -0
  32. package/src/fake-transport.ts +240 -0
  33. package/src/frame-gap.ts +41 -0
  34. package/src/frame-queue.ts +52 -0
  35. package/src/git-facts.ts +32 -0
  36. package/src/idle-watch.ts +154 -0
  37. package/src/index.ts +96 -0
  38. package/src/jsonl.ts +42 -0
  39. package/src/live-transport.ts +210 -0
  40. package/src/node-decoder-subagent.ts +67 -0
  41. package/src/node-decoder-workflow.ts +74 -0
  42. package/src/node-decoder.ts +23 -0
  43. package/src/node-decoders.ts +9 -0
  44. package/src/node-details.ts +70 -0
  45. package/src/node-path.ts +36 -0
  46. package/src/node-tracker.ts +143 -0
  47. package/src/pi-state.ts +108 -0
  48. package/src/prompt-gist.ts +13 -0
  49. package/src/prompt.ts +80 -0
  50. package/src/reap.ts +59 -0
  51. package/src/recording-transport.ts +97 -0
  52. package/src/replay-divergence.ts +155 -0
  53. package/src/replay-transport.ts +72 -0
  54. package/src/resume-preconditions.ts +59 -0
  55. package/src/resume-transport.ts +165 -0
  56. package/src/run-checkpoint.ts +93 -0
  57. package/src/run-context.ts +19 -0
  58. package/src/run.ts +274 -0
  59. package/src/skill-probe.ts +247 -0
  60. package/src/skill-restriction-transport.ts +76 -0
  61. package/src/spawn.ts +241 -0
  62. package/src/summary-agent.ts +310 -0
  63. package/src/summary-nodes.ts +77 -0
  64. package/src/summary.ts +213 -0
  65. package/src/tool-probe-extension.ts +17 -0
  66. package/src/tool-probe.ts +141 -0
  67. package/src/transport.ts +178 -0
  68. package/src/types.ts +130 -0
  69. package/src/validation-errors.ts +70 -0
  70. package/src/wire-constants.ts +24 -0
  71. package/src/worktree-transport.ts +125 -0
@@ -0,0 +1,141 @@
1
+ import { fileURLToPath } from "node:url";
2
+
3
+ import type { Frame } from "./transport.ts";
4
+
5
+ /** The private status key used only by yaag's internal pi tool probe extension. */
6
+ export const TOOL_PROBE_STATUS_KEY = "yaag.tool-probe.v1";
7
+ /** Filesystem sibling path of the probe extension; decodes percent-escapes (e.g. spaces). */
8
+ export function toolProbeExtensionPath(moduleUrl: string): string {
9
+ return fileURLToPath(new URL("./tool-probe-extension.ts", moduleUrl));
10
+ }
11
+
12
+ /** Loadable source path for the private extension that reports pi's active tools. */
13
+ export const TOOL_PROBE_EXTENSION_PATH = toolProbeExtensionPath(import.meta.url);
14
+
15
+ const TOOL_PROBE_TIMEOUT_MS = 30_000;
16
+
17
+ /** Startup collector for the private extension's effective-tool report. */
18
+ export interface ToolProbe {
19
+ /** True when the frame belongs to the probe and must not cross the transport seam. */
20
+ accept(frame: Frame): boolean;
21
+ /** Resolves with active tool names or rejects on malformed output or timeout. */
22
+ wait(): Promise<readonly string[]>;
23
+ /** Rejects startup when the spawned pi process ends before reporting. */
24
+ fail(error: Error): void;
25
+ }
26
+
27
+ /**
28
+ * Returns promised tools that survive pi's final denylist composition.
29
+ *
30
+ * Null means no tool allowlist promised anything and therefore needs no probe.
31
+ */
32
+ export function requestedToolNames(
33
+ tools: readonly string[] | undefined,
34
+ disallowedTools: readonly string[] | undefined = undefined,
35
+ ): readonly string[] | null {
36
+ if (tools === undefined || tools.length === 0) return null;
37
+ const denied = new Set(disallowedTools);
38
+ const names: string[] = [];
39
+ const seen = new Set<string>();
40
+ for (const tool of tools) {
41
+ if (denied.has(tool) || seen.has(tool)) continue;
42
+ seen.add(tool);
43
+ names.push(tool);
44
+ }
45
+ return names;
46
+ }
47
+
48
+ /** Creates a startup collector only when a non-empty allowlist promised tool selection. */
49
+ export function createToolProbe(
50
+ tools: readonly string[] | undefined,
51
+ _disallowedTools: readonly string[] | undefined = undefined,
52
+ timeoutMs = TOOL_PROBE_TIMEOUT_MS,
53
+ ): ToolProbe | null {
54
+ if (tools === undefined || tools.length === 0) return null;
55
+ let resolveResult: (names: readonly string[]) => void = () => {};
56
+ let rejectResult: (error: Error) => void = () => {};
57
+ let settled = false;
58
+ const result = new Promise<readonly string[]>((resolve, reject) => {
59
+ resolveResult = resolve;
60
+ rejectResult = reject;
61
+ });
62
+ // Startup can fail its get_state round trip before it reaches wait().
63
+ void result.catch(() => {});
64
+ const settle = (names: readonly string[]): void => {
65
+ if (settled) return;
66
+ settled = true;
67
+ clearTimeout(timer);
68
+ resolveResult(names);
69
+ };
70
+ const fail = (error: Error): void => {
71
+ if (settled) return;
72
+ settled = true;
73
+ clearTimeout(timer);
74
+ rejectResult(error);
75
+ };
76
+ const timer = setTimeout(() => {
77
+ fail(new Error("tool verification extension did not report active tools during startup"));
78
+ }, timeoutMs);
79
+ return {
80
+ accept(frame: Frame): boolean {
81
+ if (!isToolProbeFrame(frame)) return false;
82
+ try {
83
+ settle(parseToolProbeFrame(frame));
84
+ } catch (error) {
85
+ fail(error instanceof Error ? error : new Error("malformed tool probe report"));
86
+ }
87
+ return true;
88
+ },
89
+ wait(): Promise<readonly string[]> {
90
+ return result;
91
+ },
92
+ fail,
93
+ };
94
+ }
95
+
96
+ /** True exactly for a status frame emitted by the private probe extension. */
97
+ export function isToolProbeFrame(frame: Frame): boolean {
98
+ return (
99
+ frame.type === "extension_ui_request" &&
100
+ frame.method === "setStatus" &&
101
+ frame.statusKey === TOOL_PROBE_STATUS_KEY
102
+ );
103
+ }
104
+
105
+ /** Parses the private status payload, rejecting every malformed boundary. */
106
+ export function parseToolProbeFrame(frame: Frame): readonly string[] {
107
+ if (!isToolProbeFrame(frame) || typeof frame.statusText !== "string") {
108
+ throw new Error("malformed tool probe report");
109
+ }
110
+ let payload: unknown;
111
+ try {
112
+ payload = JSON.parse(frame.statusText);
113
+ } catch {
114
+ throw new Error("malformed tool probe report");
115
+ }
116
+ if (!Array.isArray(payload) || payload.some((name) => typeof name !== "string" || name === "")) {
117
+ throw new Error("malformed tool probe report");
118
+ }
119
+ const names: string[] = [];
120
+ const seen = new Set<string>();
121
+ for (const name of payload) {
122
+ if (typeof name !== "string" || seen.has(name)) continue;
123
+ seen.add(name);
124
+ names.push(name);
125
+ }
126
+ return names;
127
+ }
128
+
129
+ /** Throws a diagnostic naming every missing requested tool. */
130
+ export function verifyEffectiveTools(
131
+ promised: readonly string[],
132
+ effective: readonly string[],
133
+ ): void {
134
+ const active = new Set(effective);
135
+ const missing = promised.filter((name) => !active.has(name));
136
+ if (missing.length === 0) return;
137
+ const listed = missing.map((name) => JSON.stringify(name)).join(", ");
138
+ throw new Error(
139
+ `agent is missing requested tool(s): ${listed}; declare the extension(s) providing these tools via spawn.extensions`,
140
+ );
141
+ }
@@ -0,0 +1,178 @@
1
+ import type { CanonicalJsonObject } from "./ask-contract-identity.ts";
2
+ import type { AskLimitOutcome, AskStalledOutcome } from "./errors.ts";
3
+ import type { AskOptions, SpawnOptions, ThinkingLevel } from "./types.ts";
4
+
5
+ /**
6
+ * The one seam of the runtime (ticket 04). An AgentTransport represents a whole
7
+ * Agent and exchanges raw JSONL frames; it is the only place a process is known.
8
+ *
9
+ * Ask resolution and Lifecycle Events sit ABOVE this interface, so a replay
10
+ * implementation added later (ADR-0013) exercises the frame interpretation
11
+ * rather than bypassing it.
12
+ */
13
+
14
+ /** One JSONL frame in either direction. Unknown `type`s must survive (ticket 01). */
15
+ export type Frame = { readonly type: string; readonly [k: string]: unknown };
16
+
17
+ /** pi's full token usage for one Agent, as reported by `get_session_stats`. */
18
+ export interface TokenBreakdown {
19
+ readonly input: number;
20
+ readonly output: number;
21
+ readonly cacheRead: number;
22
+ readonly cacheWrite: number;
23
+ readonly total: number;
24
+ }
25
+
26
+ /** Cost and usage read at Agent shutdown (ADR-0012). */
27
+ export interface AgentStats {
28
+ /** Null when unavailable — e.g. killed before the first Ask completed. */
29
+ readonly tokens: TokenBreakdown | null;
30
+ readonly cost: number | null;
31
+ }
32
+
33
+ /** Recorded inputs used to explain an Ask-hash mismatch without changing identity. */
34
+ export interface AskMarkerContext {
35
+ readonly prompt: string;
36
+ readonly spawn: Pick<
37
+ SpawnOptions,
38
+ | "cwd"
39
+ | "model"
40
+ | "systemPrompt"
41
+ | "thinking"
42
+ | "appendSystemPrompt"
43
+ | "tools"
44
+ | "disallowedTools"
45
+ | "skills"
46
+ | "disallowedSkills"
47
+ | "worktree"
48
+ >;
49
+ readonly ask: Pick<
50
+ AskOptions,
51
+ "maxTurns" | "maxToolCalls" | "maxDurationMs" | "idleMs" | "wrapUpPrompt"
52
+ > & {
53
+ readonly outputSchema?: CanonicalJsonObject;
54
+ readonly maxSteers?: number;
55
+ readonly extractionPolicy?: string;
56
+ };
57
+ }
58
+
59
+ /** Opaque marker naming the Ask that subsequent frames belong to. */
60
+ export interface AskMarker {
61
+ /** Monotonic per Agent, from 0. */
62
+ readonly index: number;
63
+ /** sha256 of prompt, spawn options, soft Ask behavior, and an optional output contract. */
64
+ readonly hash: string;
65
+ /** Canonical structured-output contract, present only for schema-bearing Asks. */
66
+ readonly outputSchema?: CanonicalJsonObject;
67
+ /** Explicit structured-output correction bound; omission remains distinct from a default. */
68
+ readonly maxSteers?: number;
69
+ /** Structured-output extraction behavior identifier. */
70
+ readonly extractionPolicy?: string;
71
+ /** Definition identity for Cassette diagnostics; never participates in the hash. */
72
+ readonly definitionName?: string;
73
+ /** Hash inputs retained only for definition-spawned Ask divergence diagnostics. */
74
+ readonly context?: AskMarkerContext;
75
+ }
76
+
77
+ /** Compact Cassette result for a surfaced structured-output exhaustion. */
78
+ export interface AskInvalidOutputPlayback {
79
+ readonly kind: "invalid_output";
80
+ readonly steeringEfforts: number;
81
+ }
82
+
83
+ /** Presence identifies Cassette playback, including recorded successful Asks. */
84
+ export interface AskPlayback {
85
+ /** Limit outcome recorded for this Ask, if it rejected with ASK_LIMIT. */
86
+ readonly limit?: AskLimitOutcome;
87
+ /** Stalled outcome recorded for this Ask, if it rejected with ASK_STALLED. */
88
+ readonly stalled?: AskStalledOutcome;
89
+ /** Invalid structured-output result recorded for this Ask, if it surfaced. */
90
+ readonly outcome?: AskInvalidOutputPlayback;
91
+ /** Whether recorded correction history has an abort settlement after final text. */
92
+ readonly awaitsAbortSettlement?: true;
93
+ }
94
+
95
+ /** One Agent, as the layer above sees it. */
96
+ export interface AgentTransport {
97
+ /** Resolved model id from the startup `get_state` round-trip (ticket 03). */
98
+ readonly model: string;
99
+
100
+ /** Write one frame. Safe immediately after open: pi buffers stdin (ticket 01). */
101
+ send(frame: Frame): void;
102
+
103
+ /** Every frame from the Agent, in arrival order. Ends when the Agent is gone. */
104
+ frames(): AsyncIterable<Frame>;
105
+
106
+ /**
107
+ * Begins one Ask. Live transports return undefined; replay transports return
108
+ * playback metadata even when the recorded Ask succeeded.
109
+ */
110
+ beginAsk(marker: AskMarker): AskPlayback | undefined;
111
+
112
+ /** Reports surfaced live outcomes; replay ignores completion. */
113
+ finishAsk(
114
+ outcome?: AskLimitOutcome,
115
+ stalled?: AskStalledOutcome,
116
+ invalidOutput?: AskInvalidOutputPlayback,
117
+ ): void;
118
+
119
+ /**
120
+ * Shut the Agent down and report its cost. Idempotent.
121
+ * Owns the whole reap contract, so nothing above this interface knows what a
122
+ * signal is (ticket 02).
123
+ */
124
+ close(): Promise<AgentStats>;
125
+ }
126
+
127
+ /** Everything needed to open one Agent. */
128
+ export interface OpenOptions {
129
+ /** Base cwd when requesting a worktree; resolved cwd after the wrapper opens it. */
130
+ readonly cwd: string;
131
+ readonly name: string;
132
+ /** Deterministic request for an isolated worktree; stripped before inner open. */
133
+ readonly worktree?: true;
134
+ readonly model?: string;
135
+ readonly systemPrompt?: string;
136
+ /** Process-start discovery policy; true restores pi's normal discovery, omission or false is hermetic. */
137
+ readonly inherit?: boolean;
138
+ /** Deterministic process-start thinking level; omission preserves pi's default. */
139
+ readonly thinking?: ThinkingLevel;
140
+ /** Deterministic process-start text appended to pi's system prompt. */
141
+ readonly appendSystemPrompt?: string;
142
+ /** Tool allowlist; an empty list disables all tools. */
143
+ readonly tools?: readonly string[];
144
+ /** Tool denylist, applied by pi after the allowlist. */
145
+ readonly disallowedTools?: readonly string[];
146
+ /** Skill allowlist by discovered name; identity only, never emitted as argv paths. */
147
+ readonly skills?: readonly string[];
148
+ /** Skill denylist by discovered name; identity only, never emitted as argv paths. */
149
+ readonly disallowedSkills?: readonly string[];
150
+ /** Resolved SKILL.md paths, injected only by the live restriction wrapper. */
151
+ readonly resolvedSkillPaths?: readonly string[];
152
+ /** Absolute launch-ready extension paths resolved above the seam, emitted as repeated `-e` flags. */
153
+ readonly resolvedExtensionPaths?: readonly string[];
154
+ /** Session storage directory. Used by the e2e suite to stay out of ~/.pi (ticket 06). */
155
+ readonly sessionDir?: string;
156
+ /** Resumes an existing pi session, translated to `--session <path>`. */
157
+ readonly sessionFile?: string;
158
+ }
159
+
160
+ /** Nondeterministic worktree identity resolved below the transport seam. */
161
+ export interface WorktreeResolution {
162
+ readonly cwd: string;
163
+ readonly branch: string;
164
+ }
165
+
166
+ /** Startup facts a factory may report without widening AgentTransport. */
167
+ export interface TransportStartup {
168
+ readonly sessionFile?: string;
169
+ readonly worktree?: WorktreeResolution;
170
+ }
171
+
172
+ /** Receives startup facts after a successful factory open. */
173
+ export type TransportStartupObserver = (startup: TransportStartup) => void;
174
+
175
+ /** How the layer above obtains transports. Swapped wholesale for replay. */
176
+ export interface TransportFactory {
177
+ open(options: OpenOptions, observeStartup?: TransportStartupObserver): Promise<AgentTransport>;
178
+ }
package/src/types.ts ADDED
@@ -0,0 +1,130 @@
1
+ import type { Static, TSchema } from "typebox";
2
+
3
+ /** pi's supported thinking levels. */
4
+ export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
5
+
6
+ /** Options for spawning one Agent (ADR-0001, ADR-0009, ADR-0026). */
7
+ export interface SpawnOptions {
8
+ /** Working directory. Defaults to the Orchestrator's cwd. */
9
+ readonly cwd?: string;
10
+ /** Model pattern, e.g. "anthropic/claude-haiku-4". Unset inherits the user's default. */
11
+ readonly model?: string;
12
+ /** Replaces the default system prompt (`pi --system-prompt`). */
13
+ readonly systemPrompt?: string;
14
+ /** Sets pi's thinking level; omission preserves pi's default. */
15
+ readonly thinking?: ThinkingLevel;
16
+ /** Appends text to pi's system prompt (`pi --append-system-prompt`). */
17
+ readonly appendSystemPrompt?: string;
18
+ /**
19
+ * Restores pi discovery. Omission or false selects ADR-0026's hermetic baseline.
20
+ */
21
+ readonly inherit?: boolean;
22
+ /**
23
+ * Explicit pi extensions layer over either ADR-0026 baseline. Relative entries
24
+ * resolve from the Orchestration Program file; each becomes `pi -e <path>`.
25
+ */
26
+ readonly extensions?: readonly string[];
27
+ /**
28
+ * Tool allowlist layered over the ADR-0026 baseline; omission is tool-free when hermetic.
29
+ * `disallowedTools` applies last. A non-empty surviving allowlist is verified after startup;
30
+ * a missing promised name rejects spawn.
31
+ */
32
+ readonly tools?: readonly string[];
33
+ /**
34
+ * Tool denylist applied last under ADR-0026; `['yaag_run']` prevents nested yaag Runs.
35
+ * Denying an absent tool is inert and is not presence-validated.
36
+ */
37
+ readonly disallowedTools?: readonly string[];
38
+ /**
39
+ * Skill-name allowlist, not filesystem paths, layered over the ADR-0026 baseline.
40
+ * Omission is skill-free when hermetic; `disallowedSkills` applies last. Unknown requested
41
+ * names reject spawn.
42
+ */
43
+ readonly skills?: readonly string[];
44
+ /** Skill-name denylist discovered from pi and applied last (ADR-0026). */
45
+ readonly disallowedSkills?: readonly string[];
46
+ /** Log and event label. Defaults to a1, a2, …; duplicate names are suffixed. */
47
+ readonly name?: string;
48
+ /** Request a fresh Git worktree. The requested cwd remains the base until spawn resolves. */
49
+ readonly worktree?: boolean;
50
+ }
51
+
52
+ /** Topology-only fields that may change when spawning an Agent Definition. */
53
+ export interface SpawnOverrides {
54
+ /** Log and event label. Defaults to the definition's name; duplicates are suffixed. */
55
+ readonly name?: string;
56
+ /** Working directory. Defaults to the Orchestrator's cwd. */
57
+ readonly cwd?: string;
58
+ /** Request a fresh Git worktree. */
59
+ readonly worktree?: boolean;
60
+ }
61
+
62
+ /**
63
+ * Options for one Ask (ADR-0003).
64
+ *
65
+ * Soft limits trip at their configured count: turns are `turn_start` frames and
66
+ * tool calls are `tool_execution_start` frames between `agent_start` and
67
+ * `agent_settled`; duration is wall time from prompt send. A trip steers the
68
+ * Agent, permits one further turn (or a fixed duration grace), then aborts and
69
+ * rejects with `ASK_LIMIT` if it has not settled. This leaves the Handle alive.
70
+ * `timeoutMs` is independent: it kills the Agent and rejects with `ASK_TIMEOUT`.
71
+ */
72
+ export interface AskOptions {
73
+ /** Reject and kill the Agent if it has not settled in time. Unset = no bound. */
74
+ readonly timeoutMs?: number;
75
+ /** Soft budget of `turn_start` frames between `agent_start` and `agent_settled`. */
76
+ readonly maxTurns?: number;
77
+ /** Soft budget of `tool_execution_start` frames between `agent_start` and `agent_settled`. */
78
+ readonly maxToolCalls?: number;
79
+ /** Soft wall-clock budget in milliseconds, measured from prompt send. */
80
+ readonly maxDurationMs?: number;
81
+ /**
82
+ * Maximum silence in milliseconds: rejects with `ASK_STALLED` when no frame
83
+ * arrives for this span. Complementary to `maxDurationMs` (silence vs.
84
+ * trickle) and separate from `timeoutMs`, which always kills. Unset disables
85
+ * idle detection (ADR-0020).
86
+ */
87
+ readonly idleMs?: number;
88
+ /** Per-Ask replacement for the runtime's wrap-up steering message. */
89
+ readonly wrapUpPrompt?: string;
90
+ }
91
+
92
+ /** Ask options that require a schema and preserve its inferred successful result. */
93
+ export interface StructuredAskOptions<Schema extends TSchema> extends AskOptions {
94
+ /** TypeBox schema that changes successful Ask resolution from text to a parsed value. */
95
+ readonly outputSchema: Schema;
96
+ /** Maximum corrective steering efforts for this Ask; defaults to 3 when omitted. */
97
+ readonly maxSteers?: number;
98
+ }
99
+
100
+ /**
101
+ * A handle to one long-lived Agent.
102
+ * ADR-0003: `ask()` is the only conversational verb; everything else is data.
103
+ */
104
+ export interface Handle {
105
+ /** Stable identity, used in every Lifecycle Event and log line. */
106
+ readonly name: string;
107
+ /** Resolved absolute working directory; a worktree's actual cwd after spawn resolves. */
108
+ readonly cwd: string;
109
+ /** Fresh branch for a worktree Agent; undefined for ordinary Agents. */
110
+ readonly branch: string | undefined;
111
+ /** Model id as reported by the Agent's own `get_state` — not the requested pattern. */
112
+ readonly model: string;
113
+
114
+ /**
115
+ * Sends a prompt and resolves after the Agent's turn settles.
116
+ *
117
+ * Schema-free calls resolve with the exact final assistant text. Calls with
118
+ * `outputSchema` resolve with the extracted, TypeBox-validated value. Rejects
119
+ * with a YaagError for a failed or empty turn, timeout, `ASK_LIMIT`, or dead
120
+ * Agent; invalid structured output rejects recoverably with
121
+ * `ASK_INVALID_OUTPUT` after at most `maxSteers` (default 3) in-Ask
122
+ * correction efforts and an abort settlement. These recoverable outcomes
123
+ * leave the Handle reusable. A concurrent call rejects with `AGENT_BUSY`.
124
+ */
125
+ ask<Schema extends TSchema>(
126
+ prompt: string,
127
+ options: StructuredAskOptions<Schema>,
128
+ ): Promise<Static<Schema>>;
129
+ ask(prompt: string, options?: AskOptions): Promise<string>;
130
+ }
@@ -0,0 +1,70 @@
1
+ import type { TLocalizedValidationError } from "typebox/error";
2
+
3
+ /** Formats TypeBox errors with stable JSON-path prefixes for public diagnostics. */
4
+ export function formatValidationErrors(
5
+ value: unknown,
6
+ errors: Iterable<TLocalizedValidationError>,
7
+ ): readonly string[] {
8
+ return Array.from(errors).flatMap((error) => formatError(value, error));
9
+ }
10
+
11
+ function formatError(value: unknown, error: TLocalizedValidationError): readonly string[] {
12
+ if (error.keyword === "required") {
13
+ return error.params.requiredProperties.map(
14
+ (property) => `${toJsonPath(value, error.instancePath, property)}: ${error.message}`,
15
+ );
16
+ }
17
+ return [`${toJsonPath(value, error.instancePath)}: ${error.message}`];
18
+ }
19
+
20
+ function toJsonPath(value: unknown, instancePath: string, property?: string): string {
21
+ const segments = reconstructSegments(value, instancePath);
22
+ if (property !== undefined) segments.push(property);
23
+ return segments.reduce((path, segment) => `${path}${formatSegment(segment)}`, "$");
24
+ }
25
+
26
+ function reconstructSegments(value: unknown, instancePath: string): string[] {
27
+ if (instancePath === "") return [];
28
+ const segments: string[] = [];
29
+ let current = value;
30
+ let remaining: string | null = instancePath.slice(1);
31
+
32
+ while (remaining !== null) {
33
+ if (Array.isArray(current)) {
34
+ const [component, suffix] = splitComponent(remaining);
35
+ if (!isArrayIndex(component)) break;
36
+ segments.push(component);
37
+ current = current[Number(component)];
38
+ remaining = suffix;
39
+ continue;
40
+ }
41
+
42
+ const key = findRawPropertyKey(current, remaining);
43
+ if (key === undefined) break;
44
+ segments.push(key);
45
+ current = Object.getOwnPropertyDescriptor(current, key)?.value;
46
+ remaining = remaining === key ? null : remaining.slice(key.length + 1);
47
+ }
48
+
49
+ return remaining === null ? segments : [...segments, ...remaining.split("/")];
50
+ }
51
+
52
+ function findRawPropertyKey(value: unknown, remaining: string): string | undefined {
53
+ if (typeof value !== "object" || value === null) return undefined;
54
+ return Object.getOwnPropertyNames(value)
55
+ .filter((key) => remaining === key || remaining.startsWith(`${key}/`))
56
+ .sort((left, right) => right.length - left.length)[0];
57
+ }
58
+
59
+ function splitComponent(path: string): readonly [string, string | null] {
60
+ const separator = path.indexOf("/");
61
+ return separator === -1 ? [path, null] : [path.slice(0, separator), path.slice(separator + 1)];
62
+ }
63
+
64
+ function isArrayIndex(value: string): boolean {
65
+ return /^(0|[1-9]\d*)$/.test(value);
66
+ }
67
+
68
+ function formatSegment(segment: string): string {
69
+ return /^[A-Za-z_$][\w$]*$/.test(segment) ? `.${segment}` : `[${JSON.stringify(segment)}]`;
70
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Cap on `ask_start.promptGist` characters (ticket 09). Shared so producers
3
+ * and observers agree on how much prompt ever rides the wire — the full
4
+ * prompt never does; `promptChars` carries its true length.
5
+ */
6
+ export const PROMPT_GIST_MAX_CHARS = 160;
7
+
8
+ /** Cap on the one-line argument gist carried by `ask_activity` tool observations. */
9
+ export const TOOL_ARGS_GIST_MAX_CHARS = 120;
10
+
11
+ /** Delay before a live Ask's pending assistant output tail is emitted. */
12
+ export const ASK_OUTPUT_FLUSH_INTERVAL_MS = 250;
13
+
14
+ /** Maximum UTF-8 payload bytes in one coalesced live Ask output batch. */
15
+ export const ASK_OUTPUT_MAX_BYTES = 2 * 1024;
16
+
17
+ /** Cap on the one-line `activityGist` carried by a `node_update` event. */
18
+ export const NODE_GIST_MAX_CHARS = 120;
19
+
20
+ /** Maximum Nested Nodes the Summary keeps per Agent; past it, exited nodes prune oldest-first. */
21
+ export const AGENT_NODE_TABLE_MAX = 64;
22
+
23
+ /** Prefix marking that the oldest pending Ask output was dropped to fit the wire cap. */
24
+ export const ASK_OUTPUT_TRUNCATION_MARKER = "[…output truncated…]";
@@ -0,0 +1,125 @@
1
+ import { lstat, mkdir } from "node:fs/promises";
2
+ import { basename, dirname, join } from "node:path";
3
+ import { YaagError } from "./errors.ts";
4
+ import type {
5
+ AgentTransport,
6
+ OpenOptions,
7
+ TransportFactory,
8
+ TransportStartupObserver,
9
+ } from "./transport.ts";
10
+
11
+ interface GitResult {
12
+ readonly code: number;
13
+ readonly stdout: string;
14
+ readonly stderr: string;
15
+ }
16
+
17
+ /**
18
+ * Creates an isolated Git worktree before opening flagged Agents.
19
+ *
20
+ * Refuses non-repositories and dirty base trees with `WORKTREE_REFUSED`, before
21
+ * opening `inner`. Created worktrees and branches are deliberately never
22
+ * removed: yaag does not own the user's work after creation (ADR-0007).
23
+ */
24
+ export function worktreeTransport(inner: TransportFactory): TransportFactory {
25
+ return {
26
+ async open(
27
+ options: OpenOptions,
28
+ observeStartup?: TransportStartupObserver,
29
+ ): Promise<AgentTransport> {
30
+ if (options.worktree !== true) return inner.open(options, observeStartup);
31
+ const root = await repositoryRoot(options.cwd, options.name);
32
+ await requireClean(root, options.name);
33
+ const resolution = await addWorktree({ root, name: options.name });
34
+ const { worktree: _worktree, ...innerOptions } = options;
35
+ let reported = false;
36
+ const transport = await inner.open(
37
+ {
38
+ ...innerOptions,
39
+ cwd: resolution.cwd,
40
+ },
41
+ (startup) => {
42
+ reported = true;
43
+ observeStartup?.({ ...startup, worktree: resolution });
44
+ },
45
+ );
46
+ if (!reported) observeStartup?.({ worktree: resolution });
47
+ return transport;
48
+ },
49
+ };
50
+ }
51
+
52
+ async function repositoryRoot(cwd: string, name: string): Promise<string> {
53
+ const result = await git(cwd, ["rev-parse", "--show-toplevel"]);
54
+ const root = result.stdout.trim();
55
+ if (result.code !== 0 || root === "" || root.includes("\n")) {
56
+ throw new YaagError("WORKTREE_REFUSED", `agent "${name}": cwd is not a Git repository`, name);
57
+ }
58
+ return root;
59
+ }
60
+
61
+ async function requireClean(cwd: string, name: string): Promise<void> {
62
+ const result = await git(cwd, ["status", "--porcelain"]);
63
+ if (result.code !== 0 || result.stdout.trim() !== "") {
64
+ throw new YaagError(
65
+ "WORKTREE_REFUSED",
66
+ `agent "${name}": base repository is dirty, commit or stash changes before spawning a worktree`,
67
+ name,
68
+ );
69
+ }
70
+ }
71
+
72
+ async function addWorktree(options: { readonly root: string; readonly name: string }): Promise<{
73
+ readonly cwd: string;
74
+ readonly branch: string;
75
+ }> {
76
+ const parent = join(dirname(options.root), `${basename(options.root)}-worktrees`);
77
+ await mkdir(parent, { recursive: true });
78
+ for (let suffix = 1; ; suffix += 1) {
79
+ const leaf = suffix === 1 ? options.name : `${options.name}-${suffix}`;
80
+ const branch = `yaag/${leaf}`;
81
+ const cwd = join(parent, leaf);
82
+ if (await candidateTaken(options.root, branch, cwd)) continue;
83
+ const result = await git(options.root, ["worktree", "add", "-b", branch, cwd, "HEAD"]);
84
+ if (result.code === 0) return { cwd, branch };
85
+ if (isCollision(result)) continue;
86
+ throw new Error(`git worktree add failed: ${result.stderr.trim() || result.stdout.trim()}`);
87
+ }
88
+ }
89
+
90
+ async function candidateTaken(root: string, branch: string, cwd: string): Promise<boolean> {
91
+ const [branchResult, occupiedPath] = await Promise.all([
92
+ git(root, ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`]),
93
+ pathExists(cwd),
94
+ ]);
95
+ return branchResult.code === 0 || occupiedPath;
96
+ }
97
+
98
+ async function pathExists(path: string): Promise<boolean> {
99
+ try {
100
+ await lstat(path);
101
+ return true;
102
+ } catch {
103
+ return false;
104
+ }
105
+ }
106
+
107
+ function isCollision(result: GitResult): boolean {
108
+ return /already exists|already registered|already checked out|cannot lock ref .*reference already exists/i.test(
109
+ `${result.stdout}\n${result.stderr}`,
110
+ );
111
+ }
112
+
113
+ async function git(cwd: string, args: readonly string[]): Promise<GitResult> {
114
+ try {
115
+ const process = Bun.spawn({ cmd: ["git", "-C", cwd, ...args], stdout: "pipe", stderr: "pipe" });
116
+ const [code, stdout, stderr] = await Promise.all([
117
+ process.exited,
118
+ new Response(process.stdout).text(),
119
+ new Response(process.stderr).text(),
120
+ ]);
121
+ return { code, stdout, stderr };
122
+ } catch (error) {
123
+ return { code: -1, stdout: "", stderr: String(error) };
124
+ }
125
+ }