@yagni-app/code-staging 1.2.13-staging.2190.1 → 1.2.13-staging.2198.1

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/dist/cli.js CHANGED
@@ -32,6 +32,7 @@ import { buildLaunch } from "./launch.js";
32
32
  import { resolveOtelLaunchWithWorkspace } from "./otel.js";
33
33
  import { parseOutputFormat, parseJsonEvents, buildResultObject, readGuardianEvents, } from "./outputFormat.js";
34
34
  import { feedbackCommand } from "./feedback.js";
35
+ import { envCommand } from "./env.js";
35
36
  import { runDoctor } from "./doctor.js";
36
37
  import { installProcessCrashHandlers } from "./crashReport.js";
37
38
  import { currentCliVersion, maybeNudgeAndRefresh, upgradeCommand } from "./upgrade.js";
@@ -705,6 +706,10 @@ export const HELP_TEXT = [
705
706
  " add, install, uninstall, update, list — run",
706
707
  " `yagni plugin` for details. Skills, commands,",
707
708
  " agents, and bundled MCP servers load on launch.",
709
+ " yagni env check [--repo owner/name] Validate the repository's environment",
710
+ " as it stands, without repair, printing each step.",
711
+ " The repository defaults to the origin remote.",
712
+ " Exits 0 ready, 1 needs attention, 2 request error.",
708
713
  " yagni token Output the active environment's API token (for helpers).",
709
714
  " yagni use <name> Switch the active environment (sticky).",
710
715
  " Presets: prod, local. Others need --base-url <url>.",
@@ -866,6 +871,9 @@ export async function main(argv) {
866
871
  if (command === "feedback") {
867
872
  return feedbackCommand(rest, {}, cliVersion());
868
873
  }
874
+ if (command === "env") {
875
+ return envCommand(rest, {}, cliVersion());
876
+ }
869
877
  if (command === "token") {
870
878
  return tokenCommand({}, rest);
871
879
  }
package/dist/env.d.ts ADDED
@@ -0,0 +1,116 @@
1
+ /**
2
+ * `yagni env check [--repo owner/name]` (YAG-1109): validate a repository's
3
+ * environment as it stands, without repair, and print each step as it runs.
4
+ *
5
+ * The repository defaults to the current directory's `origin` remote. The
6
+ * command starts the check (`POST /api/yagni-code/environment/check`), then
7
+ * polls the run (`GET /api/yagni-code/environment/runs/:revisionId`) every
8
+ * 3 s. Exit codes: 0 ready, 1 needs attention, 2 a request or usage error.
9
+ *
10
+ * The remote parser, the step formatter and the poll loop are pure, with
11
+ * `fetch` and `sleep` injected, so they test without a network.
12
+ */
13
+ export type EnvRunStatus = "validating" | "ready" | "needs_attention";
14
+ export type EnvStepState = "pending" | "running" | "done" | "failed" | "skipped";
15
+ /** One step of the run, as the server returns it (`QaRunStep`). */
16
+ export interface EnvRunStep {
17
+ index: number;
18
+ kind?: string;
19
+ label: string;
20
+ path?: string;
21
+ line?: number;
22
+ state: EnvStepState;
23
+ durationMs?: number;
24
+ exitCode?: number;
25
+ outputTail?: string;
26
+ }
27
+ export interface EnvRun {
28
+ status: EnvRunStatus;
29
+ message: string;
30
+ steps: EnvRunStep[];
31
+ notes: string[];
32
+ outputTail?: string;
33
+ /** The declared system the run validated in, or why it ran alone. */
34
+ system?: EnvRunSystem;
35
+ }
36
+ export interface EnvRunSystem {
37
+ name: string;
38
+ applied?: boolean;
39
+ reason?: "not_member" | "unreadable" | "missing";
40
+ }
41
+ /** `in system gpx`, or `ran alone: <why>` when the system was not applied. */
42
+ export declare function formatSystem(system: EnvRunSystem): string;
43
+ export declare const ENV_POLL_INTERVAL_MS = 3000;
44
+ export declare const ENV_USAGE: string;
45
+ /**
46
+ * `owner/name` from a git remote URL: https (`https://github.com/o/n.git`),
47
+ * ssh (`ssh://git@github.com/o/n`) or scp-like (`git@github.com:o/n.git`).
48
+ * The `.git` suffix and a trailing slash are optional. Undefined otherwise.
49
+ */
50
+ export declare function parseRemoteRepo(url: string): string | undefined;
51
+ /**
52
+ * A remote URL safe to print: the username and password (a token, often)
53
+ * are dropped from a URL remote, and the user from an scp-like one.
54
+ */
55
+ export declare function redactRemote(url: string): string;
56
+ /** A validated `owner/name` given on the command line, `.git` allowed. */
57
+ export declare function parseRepoArg(raw: string): string | undefined;
58
+ /**
59
+ * A step's time as a person reads it: `41 s`, `11 m 20 s`, `2 h 5 m`. Mirrors
60
+ * `qaStepDuration` in `@yagni/shared` (`packages/shared/src/qa.ts`), which this
61
+ * self-contained package cannot import; keep the two in step.
62
+ */
63
+ export declare function formatDuration(ms: number): string;
64
+ /**
65
+ * The lines one step prints when it reaches a state. `pending` prints
66
+ * nothing; a failed step prints its output tail, indented.
67
+ */
68
+ export declare function formatStep(step: EnvRunStep): string[];
69
+ /**
70
+ * The lines this read adds: every step whose state moved since the last
71
+ * read. `seen` is updated in place.
72
+ */
73
+ export declare function stepTransitions(steps: EnvRunStep[], seen: Map<number, EnvStepState>): string[];
74
+ export interface EnvRequest {
75
+ baseUrl: string;
76
+ token: string;
77
+ fetchImpl: typeof fetch;
78
+ }
79
+ /** Start the check: the run's revision id, or the reason it did not start. */
80
+ export declare function startCheck(req: EnvRequest, repo: string): Promise<{
81
+ revisionId: string;
82
+ } | {
83
+ error: string;
84
+ }>;
85
+ export interface PollOptions extends EnvRequest {
86
+ revisionId: string;
87
+ sleep: (ms: number) => Promise<void>;
88
+ writeOut: (line: string) => void;
89
+ writeErr: (line: string) => void;
90
+ intervalMs?: number;
91
+ }
92
+ /** Poll the run until it settles; the command's exit code. */
93
+ export declare function pollRun(opts: PollOptions): Promise<number>;
94
+ export interface EnvDeps {
95
+ loadCredentials?: () => Promise<{
96
+ token?: string;
97
+ baseUrl: string;
98
+ name: string;
99
+ }>;
100
+ fetchImpl?: typeof fetch;
101
+ sleep?: (ms: number) => Promise<void>;
102
+ /** The current directory's `origin` remote URL, or undefined. */
103
+ originUrl?: (cwd: string) => Promise<string | undefined>;
104
+ cwd?: string;
105
+ env?: NodeJS.ProcessEnv;
106
+ writeOut?: (line: string) => void;
107
+ writeErr?: (line: string) => void;
108
+ }
109
+ /** `--repo owner/name` or `--repo=owner/name`; `error` names a bad argument. */
110
+ export declare function parseEnvCheckArgs(args: string[]): {
111
+ repo?: string;
112
+ error?: string;
113
+ };
114
+ /** `yagni env <subcommand>`; only `check` exists. */
115
+ export declare function envCommand(args: string[], deps?: EnvDeps, _cliVersion?: string): Promise<number>;
116
+ //# sourceMappingURL=env.d.ts.map
package/dist/env.js ADDED
@@ -0,0 +1,332 @@
1
+ /**
2
+ * `yagni env check [--repo owner/name]` (YAG-1109): validate a repository's
3
+ * environment as it stands, without repair, and print each step as it runs.
4
+ *
5
+ * The repository defaults to the current directory's `origin` remote. The
6
+ * command starts the check (`POST /api/yagni-code/environment/check`), then
7
+ * polls the run (`GET /api/yagni-code/environment/runs/:revisionId`) every
8
+ * 3 s. Exit codes: 0 ready, 1 needs attention, 2 a request or usage error.
9
+ *
10
+ * The remote parser, the step formatter and the poll loop are pure, with
11
+ * `fetch` and `sleep` injected, so they test without a network.
12
+ */
13
+ import { execFile } from "node:child_process";
14
+ import { credentialsFromProfile, readActiveProfile } from "./profiles.js";
15
+ /** `in system gpx`, or `ran alone: <why>` when the system was not applied. */
16
+ export function formatSystem(system) {
17
+ if (system.applied !== false)
18
+ return `in system ${system.name}`;
19
+ const why = system.reason === "not_member"
20
+ ? `not a member of system ${system.name}`
21
+ : system.reason === "unreadable"
22
+ ? `system ${system.name} could not be read`
23
+ : system.reason === "missing"
24
+ ? `system ${system.name} no longer exists`
25
+ : `system ${system.name} was not applied`;
26
+ return `ran alone: ${why}`;
27
+ }
28
+ export const ENV_POLL_INTERVAL_MS = 3_000;
29
+ /** Consecutive failed reads of the run before the command gives up. */
30
+ const MAX_READ_FAILURES = 3;
31
+ export const ENV_USAGE = [
32
+ "Usage: yagni env check [--repo owner/name]",
33
+ "",
34
+ "Validates the repository's environment as it stands, without repair.",
35
+ "The repository defaults to this directory's origin remote.",
36
+ "Exits 0 when ready, 1 when it needs attention, 2 on a request error.",
37
+ ].join("\n");
38
+ /**
39
+ * `owner/name` from a git remote URL: https (`https://github.com/o/n.git`),
40
+ * ssh (`ssh://git@github.com/o/n`) or scp-like (`git@github.com:o/n.git`).
41
+ * The `.git` suffix and a trailing slash are optional. Undefined otherwise.
42
+ */
43
+ export function parseRemoteRepo(url) {
44
+ const trimmed = url
45
+ .trim()
46
+ .replace(/\/+$/, "")
47
+ .replace(/\.git$/, "");
48
+ let path;
49
+ if (trimmed.includes("://")) {
50
+ try {
51
+ path = new URL(trimmed).pathname;
52
+ }
53
+ catch {
54
+ return undefined;
55
+ }
56
+ }
57
+ else {
58
+ const scp = /^(?:[^@/\s]+@)?[^:/\s]+:(.+)$/.exec(trimmed);
59
+ if (!scp)
60
+ return undefined;
61
+ path = scp[1];
62
+ }
63
+ // Exactly owner and name: a longer path (an Azure DevOps `_git` path, a
64
+ // nested group) is never guessed at.
65
+ const segments = path.split("/").filter(Boolean);
66
+ if (segments.length !== 2)
67
+ return undefined;
68
+ const [owner, name] = segments;
69
+ const part = /^[A-Za-z0-9._-]+$/;
70
+ return part.test(owner) && part.test(name) ? `${owner}/${name}` : undefined;
71
+ }
72
+ /**
73
+ * A remote URL safe to print: the username and password (a token, often)
74
+ * are dropped from a URL remote, and the user from an scp-like one.
75
+ */
76
+ export function redactRemote(url) {
77
+ const trimmed = url.trim();
78
+ if (trimmed.includes("://")) {
79
+ try {
80
+ const parsed = new URL(trimmed);
81
+ parsed.username = "";
82
+ parsed.password = "";
83
+ return parsed.toString();
84
+ }
85
+ catch {
86
+ return trimmed.replace(/^([^:/]+:\/\/)[^/]*@/, "$1");
87
+ }
88
+ }
89
+ return trimmed.replace(/^[^/\s]*@/, "");
90
+ }
91
+ /** A validated `owner/name` given on the command line, `.git` allowed. */
92
+ export function parseRepoArg(raw) {
93
+ const repo = raw.trim().replace(/\.git$/, "");
94
+ return /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(repo) ? repo : undefined;
95
+ }
96
+ /**
97
+ * A step's time as a person reads it: `41 s`, `11 m 20 s`, `2 h 5 m`. Mirrors
98
+ * `qaStepDuration` in `@yagni/shared` (`packages/shared/src/qa.ts`), which this
99
+ * self-contained package cannot import; keep the two in step.
100
+ */
101
+ export function formatDuration(ms) {
102
+ const seconds = Math.max(0, Math.round(ms / 1000));
103
+ if (seconds < 60)
104
+ return `${seconds} s`;
105
+ const minutes = Math.floor(seconds / 60);
106
+ if (minutes < 60)
107
+ return seconds % 60 ? `${minutes} m ${seconds % 60} s` : `${minutes} m`;
108
+ const hours = Math.floor(minutes / 60);
109
+ return minutes % 60 ? `${hours} h ${minutes % 60} m` : `${hours} h`;
110
+ }
111
+ /**
112
+ * The lines one step prints when it reaches a state. `pending` prints
113
+ * nothing; a failed step prints its output tail, indented.
114
+ */
115
+ export function formatStep(step) {
116
+ const line = step.line !== undefined ? ` (line ${step.line})` : "";
117
+ const took = step.durationMs !== undefined ? ` ${formatDuration(step.durationMs)}` : "";
118
+ switch (step.state) {
119
+ case "running":
120
+ return [`… ${step.label}`];
121
+ case "done":
122
+ return [`✓ ${step.label}${took}`];
123
+ case "skipped":
124
+ return [`- ${step.label} skipped`];
125
+ case "failed": {
126
+ const tail = (step.outputTail ?? "")
127
+ .split("\n")
128
+ .filter((l) => l.trim().length > 0)
129
+ .map((l) => ` ${l}`);
130
+ return [`✗ ${step.label}${line}${took}`, ...tail];
131
+ }
132
+ default:
133
+ return [];
134
+ }
135
+ }
136
+ /**
137
+ * The lines this read adds: every step whose state moved since the last
138
+ * read. `seen` is updated in place.
139
+ */
140
+ export function stepTransitions(steps, seen) {
141
+ const out = [];
142
+ for (const step of [...steps].sort((a, b) => a.index - b.index)) {
143
+ if (seen.get(step.index) === step.state)
144
+ continue;
145
+ seen.set(step.index, step.state);
146
+ out.push(...formatStep(step));
147
+ }
148
+ return out;
149
+ }
150
+ function apiUrl(baseUrl, path) {
151
+ return `${baseUrl.replace(/\/$/, "")}/api/yagni-code${path}`;
152
+ }
153
+ async function errorText(res) {
154
+ const body = (await res.json().catch(() => ({})));
155
+ const detail = typeof body.error === "string" ? body.error : "";
156
+ if (res.status === 401)
157
+ return "Your login has expired. Run `yagni login` and try again.";
158
+ return detail ? `${detail} (HTTP ${res.status})` : `The server answered HTTP ${res.status}.`;
159
+ }
160
+ /** Start the check: the run's revision id, or the reason it did not start. */
161
+ export async function startCheck(req, repo) {
162
+ try {
163
+ const res = await req.fetchImpl(apiUrl(req.baseUrl, "/environment/check"), {
164
+ method: "POST",
165
+ headers: { "content-type": "application/json", authorization: `Bearer ${req.token}` },
166
+ body: JSON.stringify({ repo }),
167
+ signal: AbortSignal.timeout(60_000),
168
+ });
169
+ if (!res.ok)
170
+ return { error: await errorText(res) };
171
+ const body = (await res.json());
172
+ return typeof body.revisionId === "string"
173
+ ? { revisionId: body.revisionId }
174
+ : { error: "The server did not say which run started." };
175
+ }
176
+ catch (err) {
177
+ return { error: err instanceof Error ? err.message : String(err) };
178
+ }
179
+ }
180
+ async function readRun(req, revisionId) {
181
+ try {
182
+ const res = await req.fetchImpl(apiUrl(req.baseUrl, `/environment/runs/${encodeURIComponent(revisionId)}`), {
183
+ headers: { authorization: `Bearer ${req.token}` },
184
+ signal: AbortSignal.timeout(30_000),
185
+ });
186
+ if (!res.ok)
187
+ return { error: await errorText(res) };
188
+ const body = (await res.json());
189
+ return {
190
+ status: body.status ?? "validating",
191
+ message: body.message ?? "",
192
+ steps: Array.isArray(body.steps) ? body.steps : [],
193
+ notes: Array.isArray(body.notes) ? body.notes : [],
194
+ ...(body.outputTail ? { outputTail: body.outputTail } : {}),
195
+ ...(body.system && typeof body.system.name === "string" ? { system: body.system } : {}),
196
+ };
197
+ }
198
+ catch (err) {
199
+ return { error: err instanceof Error ? err.message : String(err) };
200
+ }
201
+ }
202
+ /** Poll the run until it settles; the command's exit code. */
203
+ export async function pollRun(opts) {
204
+ const seen = new Map();
205
+ let failures = 0;
206
+ let systemSaid = false;
207
+ for (;;) {
208
+ const run = await readRun(opts, opts.revisionId);
209
+ if ("error" in run) {
210
+ failures += 1;
211
+ if (failures >= MAX_READ_FAILURES) {
212
+ opts.writeErr(`Could not read the run: ${run.error}`);
213
+ return 2;
214
+ }
215
+ }
216
+ else {
217
+ failures = 0;
218
+ if (run.system && !systemSaid) {
219
+ systemSaid = true;
220
+ opts.writeOut(formatSystem(run.system));
221
+ }
222
+ for (const line of stepTransitions(run.steps, seen))
223
+ opts.writeOut(line);
224
+ if (run.status !== "validating") {
225
+ const failedTail = run.steps.some((s) => s.state === "failed" && s.outputTail);
226
+ if (run.status === "needs_attention" && run.outputTail && !failedTail) {
227
+ for (const line of run.outputTail.split("\n").filter((l) => l.trim()))
228
+ opts.writeOut(` ${line}`);
229
+ }
230
+ for (const note of run.notes)
231
+ opts.writeOut(`note: ${note}`);
232
+ if (run.status === "ready") {
233
+ opts.writeOut(run.message ? `Ready. ${run.message}` : "Ready.");
234
+ return 0;
235
+ }
236
+ opts.writeOut(run.message ? `Needs attention: ${run.message}` : "Needs attention.");
237
+ return 1;
238
+ }
239
+ }
240
+ await opts.sleep(opts.intervalMs ?? ENV_POLL_INTERVAL_MS);
241
+ }
242
+ }
243
+ function gitOriginUrl(cwd) {
244
+ return new Promise((resolve) => {
245
+ execFile("git", ["remote", "get-url", "origin"], { cwd, timeout: 10_000 }, (err, stdout) => resolve(err ? undefined : stdout.trim() || undefined));
246
+ });
247
+ }
248
+ /** `--repo owner/name` or `--repo=owner/name`; `error` names a bad argument. */
249
+ export function parseEnvCheckArgs(args) {
250
+ let repo;
251
+ for (let i = 0; i < args.length; i++) {
252
+ const a = args[i];
253
+ if (a === "--repo") {
254
+ const value = args[++i];
255
+ if (!value)
256
+ return { error: "--repo needs owner/name" };
257
+ repo = value;
258
+ }
259
+ else if (a.startsWith("--repo=")) {
260
+ repo = a.slice("--repo=".length);
261
+ }
262
+ else {
263
+ return { error: `Unknown argument: ${a}` };
264
+ }
265
+ }
266
+ if (repo === undefined)
267
+ return {};
268
+ const parsed = parseRepoArg(repo);
269
+ return parsed ? { repo: parsed } : { error: `Not a repository: ${repo} (expected owner/name)` };
270
+ }
271
+ /** `yagni env <subcommand>`; only `check` exists. */
272
+ export async function envCommand(args, deps = {}, _cliVersion) {
273
+ const writeOut = deps.writeOut ?? ((line) => void process.stdout.write(`${line}\n`));
274
+ const writeErr = deps.writeErr ?? ((line) => void process.stderr.write(`${line}\n`));
275
+ const [sub, ...rest] = args;
276
+ if (sub !== "check") {
277
+ (sub === undefined ? writeOut : writeErr)(ENV_USAGE);
278
+ return sub === undefined ? 0 : 2;
279
+ }
280
+ const parsed = parseEnvCheckArgs(rest);
281
+ if (parsed.error) {
282
+ writeErr(parsed.error);
283
+ writeErr(ENV_USAGE);
284
+ return 2;
285
+ }
286
+ const cwd = deps.cwd ?? process.cwd();
287
+ let repo = parsed.repo;
288
+ if (!repo) {
289
+ const url = await (deps.originUrl ?? gitOriginUrl)(cwd);
290
+ repo = url ? parseRemoteRepo(url) : undefined;
291
+ if (!repo) {
292
+ writeErr(url
293
+ ? `Could not read the repository from the origin remote (${redactRemote(url)}). Pass --repo owner/name.`
294
+ : "This directory has no origin remote. Pass --repo owner/name.");
295
+ return 2;
296
+ }
297
+ }
298
+ const env = deps.env ?? process.env;
299
+ const profile = await (deps.loadCredentials ??
300
+ (async () => {
301
+ const active = await readActiveProfile(env);
302
+ const creds = credentialsFromProfile(active);
303
+ return {
304
+ name: active.name,
305
+ baseUrl: active.baseUrl,
306
+ ...(creds?.token ? { token: creds.token } : {}),
307
+ };
308
+ }))();
309
+ if (!profile.token) {
310
+ writeErr(`Not logged in to environment "${profile.name}" (${profile.baseUrl}). Run \`yagni login\` first.`);
311
+ return 2;
312
+ }
313
+ const req = {
314
+ baseUrl: profile.baseUrl,
315
+ token: profile.token,
316
+ fetchImpl: deps.fetchImpl ?? fetch,
317
+ };
318
+ const started = await startCheck(req, repo);
319
+ if ("error" in started) {
320
+ writeErr(`Could not start the check for ${repo}: ${started.error}`);
321
+ return 2;
322
+ }
323
+ writeOut(`Validating ${repo} as it stands, without repair.`);
324
+ return pollRun({
325
+ ...req,
326
+ revisionId: started.revisionId,
327
+ sleep: deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
328
+ writeOut,
329
+ writeErr,
330
+ });
331
+ }
332
+ //# sourceMappingURL=env.js.map
@@ -48,6 +48,7 @@ import { createGrantCensus } from "./permission/grantCensus.js";
48
48
  import { runsInSandboxHarness, toolchainBashToolDefinition, toolchainSpawnHookFromEnv, toolchainUserBashOperations, } from "./toolchain/spawnHook.js";
49
49
  import { CLOUD_SESSION_ENV } from "./pipeline/cloud.js";
50
50
  import { registerStageClock } from "./pipeline/stageClock.js";
51
+ import { registerBashDefaultTimeout } from "./toolchain/bashDefaultTimeout.js";
51
52
  import { scrubSecrets } from "./pipeline/scrubSecrets.js";
52
53
  import { registerTelemetry as defaultRegisterTelemetry } from "./telemetry/register.js";
53
54
  import { loadHooksConfig, makeHookRunner, registerHooks } from "./hooks.js";
@@ -176,6 +177,9 @@ export async function registerYagni(pi, deps = {}) {
176
177
  // not matter: pi runs every tool_call handler until one blocks, and an allow
177
178
  // (a result without `block`) never ends the loop, so this block always lands.
178
179
  registerStageClock(pi, env, now);
180
+ // A sandbox session's bash call without a timeout gets a default one, so a
181
+ // command that never exits cannot hold the session. It never blocks.
182
+ registerBashDefaultTimeout(pi, env);
179
183
  // The grounding settings switch (the judgment-loop kill switch): resolved
180
184
  // ONCE at boot from the USER-tier config (default on; a malformed config
181
185
  // fails soft to on with a sink warn). Eval mode is its own carve-out and
@@ -0,0 +1,25 @@
1
+ /**
2
+ * A default timeout for bash in a sandbox session. Pi's bash has none, so a
3
+ * command that never exits (a server, an interactive prompt) holds the whole
4
+ * session until an outer clock stops it. A rails build session on staging
5
+ * went quiet for 35 minutes after one such call (YAG-1130).
6
+ */
7
+ import { type ExtensionAPI } from "@earendil-works/pi-coding-agent";
8
+ /**
9
+ * Ten minutes: long enough for an install or a test suite, short enough that a
10
+ * command that never exits does not hold the session. The model can pass a
11
+ * longer `timeout`, and pi's timed-out result tells it so.
12
+ */
13
+ export declare const SANDBOX_BASH_TIMEOUT_SECONDS = 600;
14
+ /**
15
+ * Give every bash call without a positive timeout the default one. pi's bash
16
+ * treats 0 as no timeout, so 0, negatives and non-numbers get it too; every
17
+ * positive number the model asked for stays. A background call
18
+ * (`run_in_background`) is left alone: it returns at once, so it cannot hold
19
+ * the session, and the sandbox's background job (`sandbox/session.ts`) kills
20
+ * the job when its timeout runs out, which would stop a backgrounded server.
21
+ * It never blocks. A developer's own terminal (no sandbox harness) gets
22
+ * nothing.
23
+ */
24
+ export declare function registerBashDefaultTimeout(pi: Pick<ExtensionAPI, "on">, env: NodeJS.ProcessEnv, seconds?: number): void;
25
+ //# sourceMappingURL=bashDefaultTimeout.d.ts.map
@@ -0,0 +1,40 @@
1
+ /**
2
+ * A default timeout for bash in a sandbox session. Pi's bash has none, so a
3
+ * command that never exits (a server, an interactive prompt) holds the whole
4
+ * session until an outer clock stops it. A rails build session on staging
5
+ * went quiet for 35 minutes after one such call (YAG-1130).
6
+ */
7
+ import { isToolCallEventType } from "@earendil-works/pi-coding-agent";
8
+ import { runsInSandboxHarness } from "./spawnHook.js";
9
+ /**
10
+ * Ten minutes: long enough for an install or a test suite, short enough that a
11
+ * command that never exits does not hold the session. The model can pass a
12
+ * longer `timeout`, and pi's timed-out result tells it so.
13
+ */
14
+ export const SANDBOX_BASH_TIMEOUT_SECONDS = 600;
15
+ /**
16
+ * Give every bash call without a positive timeout the default one. pi's bash
17
+ * treats 0 as no timeout, so 0, negatives and non-numbers get it too; every
18
+ * positive number the model asked for stays. A background call
19
+ * (`run_in_background`) is left alone: it returns at once, so it cannot hold
20
+ * the session, and the sandbox's background job (`sandbox/session.ts`) kills
21
+ * the job when its timeout runs out, which would stop a backgrounded server.
22
+ * It never blocks. A developer's own terminal (no sandbox harness) gets
23
+ * nothing.
24
+ */
25
+ export function registerBashDefaultTimeout(pi, env, seconds = SANDBOX_BASH_TIMEOUT_SECONDS) {
26
+ if (!runsInSandboxHarness(env))
27
+ return;
28
+ pi.on("tool_call", async (event) => {
29
+ if (!isToolCallEventType("bash", event))
30
+ return undefined;
31
+ const input = event.input;
32
+ if (input.run_in_background === true)
33
+ return undefined;
34
+ if (typeof input.timeout === "number" && input.timeout > 0)
35
+ return undefined;
36
+ event.input.timeout = seconds;
37
+ return undefined;
38
+ });
39
+ }
40
+ //# sourceMappingURL=bashDefaultTimeout.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "1.2.13-staging.2190.1",
3
+ "version": "1.2.13-staging.2198.1",
4
4
  "description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
@@ -58,5 +58,5 @@
58
58
  "turndown": "^7.2.4",
59
59
  "typebox": "^1.3.15"
60
60
  },
61
- "yagniSourceSha": "515314459213f3e3acff697e5807da8ae3c5ce4a"
61
+ "yagniSourceSha": "a784a903593e5a0174b06dff4e34cc21fff4be4b"
62
62
  }