@alisio/core 0.1.1 → 0.2.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.
@@ -16,6 +16,7 @@ import { subagentTasks } from "./background/mirror.js";
16
16
  import { BackgroundTasks } from "./background/service.js";
17
17
  import { configFile, configHome, loadConfigWithProvenance, overridesSavedProviderProfile, setConfigValue, setGlobalMcpAllow, setMcpServerEnabled, setProjectPluginEnabled, setProjectSkillEnabled, stateHome, } from "./config.js";
18
18
  import { completeText } from "./core/compaction.js";
19
+ import { HumanWaits } from "./core/human-wait.js";
19
20
  import { DEFAULT_MAX_OUTPUT_TOKENS } from "./core/output-limit.js";
20
21
  import { ToolRegistry } from "./core/registry.js";
21
22
  import { AgentRunner } from "./core/runner.js";
@@ -86,11 +87,25 @@ export async function createApplication(options = {}) {
86
87
  const addDirs = options.readOnly
87
88
  ? []
88
89
  : await Promise.all((options.addDirs ?? []).map((dir) => realpath(resolve(cwd, dir)).catch(() => resolve(cwd, dir))));
90
+ // Waits for a person (approvals, questions, the plan review) do not count against the run's
91
+ // time limit. One registry serves the runner, the plugin host's interactive UI and the
92
+ // external-directory approval, so every host (TUI, web server, embedders) gets it for free.
93
+ const humanWaits = new HumanWaits((session) => {
94
+ try {
95
+ return store.get(session).parentId ?? undefined;
96
+ }
97
+ catch {
98
+ return undefined;
99
+ }
100
+ });
101
+ const askExternalDirectory = options.approveExternalDirectory;
89
102
  const pathAccess = new PathAccess({
90
103
  workspace,
91
104
  extraRoots: [...config.additionalDirectories, ...addDirs],
92
- ...(options.approveExternalDirectory && !options.readOnly
93
- ? { approve: options.approveExternalDirectory }
105
+ ...(askExternalDirectory && !options.readOnly
106
+ ? {
107
+ approve: (request) => humanWaits.track(request.session, () => askExternalDirectory(request)),
108
+ }
94
109
  : {}),
95
110
  readOnly: !!options.readOnly,
96
111
  });
@@ -413,7 +428,15 @@ export async function createApplication(options = {}) {
413
428
  }
414
429
  // `exit_plan` (opt-in: only the plan agent's run sees it): the plan review hand-over.
415
430
  const { registerExitPlan } = await import("./plan/exit-plan.js");
416
- registerExitPlan(registry, { store, ui: plugins.ui, readOnly: !!options.readOnly });
431
+ registerExitPlan(registry, {
432
+ store,
433
+ ui: plugins.ui,
434
+ readOnly: !!options.readOnly,
435
+ diagrams: () => ({
436
+ enabled: config.plan.diagrams,
437
+ max: config.plan.diagrams ? config.plan.maxDiagrams : 0,
438
+ }),
439
+ });
417
440
  // Data tools only read (effect `read`): they stay available under --read-only.
418
441
  if (config.analysis.enabled) {
419
442
  const { registerDataTools } = await import("./tools/data.js");
@@ -722,7 +745,9 @@ export async function createApplication(options = {}) {
722
745
  : [];
723
746
  return declared.length ? Math.min(...declared) : undefined;
724
747
  };
748
+ plugins.setHumanWaits(humanWaits);
725
749
  const runner = new AgentRunner({
750
+ humanWaits,
726
751
  provider,
727
752
  providerFor,
728
753
  registry,
@@ -1152,6 +1177,13 @@ export async function createApplication(options = {}) {
1152
1177
  };
1153
1178
  break;
1154
1179
  }
1180
+ case "plan.diagrams":
1181
+ // Read live: `exit_plan` offers (or hides) its `diagrams` field on the next request.
1182
+ config.plan = { ...config.plan, diagrams: value === true };
1183
+ break;
1184
+ case "plan.maxDiagrams":
1185
+ config.plan = { ...config.plan, maxDiagrams: Number(value) };
1186
+ break;
1155
1187
  case "tasks.enabled":
1156
1188
  // Tools are registered when the application starts: the change applies to the next
1157
1189
  // application (a restart; the web recycles the workspace application).
@@ -0,0 +1,8 @@
1
+ /**
2
+ * A cheap, deterministic quality gate for HTML dashboards: it spots the chart mistakes that models
3
+ * make when they hand-write charts (SVG pie arcs, fixed-size SVGs, libraries loaded from a CDN that
4
+ * the viewer blocks) and names the helper that avoids them. It only produces warnings for the
5
+ * `python_run` / `artifact_create` result; it never rejects an artifact.
6
+ */
7
+ /** Warnings (empty when the page looks fine) for one HTML document. */
8
+ export declare function chartWarnings(html: string): string[];
@@ -0,0 +1,84 @@
1
+ /**
2
+ * A cheap, deterministic quality gate for HTML dashboards: it spots the chart mistakes that models
3
+ * make when they hand-write charts (SVG pie arcs, fixed-size SVGs, libraries loaded from a CDN that
4
+ * the viewer blocks) and names the helper that avoids them. It only produces warnings for the
5
+ * `python_run` / `artifact_create` result; it never rejects an artifact.
6
+ */
7
+ const HELPERS = "alisio_runtime.charts (Chart.js, interactive) or alisio_runtime.svg (static)";
8
+ /** Strips scripts and styles so a bundled library never triggers the markup checks. */
9
+ function markupOf(html) {
10
+ return html
11
+ .replace(/(<script\b[^>]*>)[\s\S]*?<\/script\s*>/gi, "$1</script>")
12
+ .replace(/<style\b[^>]*>[\s\S]*?<\/style\s*>/gi, "<style></style>");
13
+ }
14
+ /** `d` attributes that draw a wedge: a line to/from the centre, an arc command and a close. */
15
+ function wedgeCount(markup) {
16
+ let count = 0;
17
+ for (const match of markup.matchAll(/<path\b[^>]*?\sd\s*=\s*["']([^"']*)["']/gi)) {
18
+ const d = match[1] ?? "";
19
+ if (/[Aa]\s*[\d.]+[\s,]+[\d.]+[\s,]+-?[\d.]+[\s,]+[01][\s,]*[01]/.test(d) &&
20
+ /[Ll]/.test(d) &&
21
+ /[Zz]/.test(d))
22
+ count++;
23
+ }
24
+ return count;
25
+ }
26
+ const numeric = (value) => {
27
+ const match = /^\s*(\d+(?:\.\d+)?)(?:px)?\s*$/.exec(value ?? "");
28
+ return match ? Number(match[1]) : undefined;
29
+ };
30
+ /** Fixed-size SVG charts: numeric width and height, no `viewBox` (they do not scale with the card). */
31
+ function fixedSvgCount(markup) {
32
+ let count = 0;
33
+ for (const block of markup.split(/<svg\b/i).slice(1)) {
34
+ const tagEnd = block.indexOf(">");
35
+ if (tagEnd < 0)
36
+ continue;
37
+ const tag = block.slice(0, tagEnd);
38
+ const body = block.slice(tagEnd, Math.max(tagEnd, block.search(/<\/svg\s*>/i)));
39
+ const attr = (name) => new RegExp(`\\s${name}\\s*=\\s*["']([^"']*)["']`, "i").exec(tag)?.[1];
40
+ const width = numeric(attr("width"));
41
+ const height = numeric(attr("height"));
42
+ const drawn = (body.match(/<(?:rect|path|polyline|circle|line)\b/gi) ?? []).length;
43
+ if (width !== undefined &&
44
+ height !== undefined &&
45
+ width >= 150 &&
46
+ drawn >= 4 &&
47
+ !/\sviewBox\s*=/i.test(tag))
48
+ count++;
49
+ }
50
+ return count;
51
+ }
52
+ /** `<script src>`, `<link href>` and `@import` that point at the network. */
53
+ function remoteHosts(markup, html) {
54
+ const hosts = new Set();
55
+ const sources = [
56
+ ...markup.matchAll(/<script\b[^>]*\ssrc\s*=\s*["']((?:https?:)?\/\/[^"']+)["']/gi),
57
+ ...markup.matchAll(/<link\b[^>]*\shref\s*=\s*["']((?:https?:)?\/\/[^"']+)["']/gi),
58
+ ...html.matchAll(/@import\s+(?:url\()?["']?((?:https?:)?\/\/[^"')\s]+)/gi),
59
+ ];
60
+ for (const match of sources) {
61
+ try {
62
+ hosts.add(new URL(match[1].replace(/^\/\//, "https://")).host);
63
+ }
64
+ catch {
65
+ // Not a URL: ignore it.
66
+ }
67
+ }
68
+ return [...hosts];
69
+ }
70
+ /** Warnings (empty when the page looks fine) for one HTML document. */
71
+ export function chartWarnings(html) {
72
+ const markup = markupOf(html);
73
+ const warnings = [];
74
+ const hosts = remoteHosts(markup, html);
75
+ if (hosts.length)
76
+ warnings.push(`loads resources from the network (${hosts.slice(0, 3).join(", ")}); the viewer blocks the network, so they will not load. Charts: use ${HELPERS}, which inlines Chart.js`);
77
+ const wedges = wedgeCount(markup);
78
+ if (wedges >= 2)
79
+ warnings.push(`${wedges} hand-written SVG arc paths look like a pie or donut; hand-written arcs are often wrong (large-arc flag, 100% slice). Use charts.pie/donut or svg.pie/donut`);
80
+ const fixed = fixedSvgCount(markup);
81
+ if (fixed)
82
+ warnings.push(`${fixed} SVG chart${fixed === 1 ? "" : "s"} with a fixed width and height and no viewBox will not scale with its container (small text, empty space). Use ${HELPERS}, or give the SVG a viewBox and width:100%`);
83
+ return warnings;
84
+ }
@@ -8,6 +8,9 @@ export function createArtifactPublisher(store, owner, announce) {
8
8
  async publish(input) {
9
9
  return done(await store.publish(input, owner)).artifact;
10
10
  },
11
+ async publishDetailed(input) {
12
+ return done(await store.publish(input, owner));
13
+ },
11
14
  async publishText(input) {
12
15
  return done(await store.publishText(input, owner)).artifact;
13
16
  },
@@ -71,7 +71,9 @@ export declare class ArtifactStore {
71
71
  title?: string;
72
72
  }): Promise<PublishedArtifact[]>;
73
73
  /** Publishes one caller-owned file or directory. */
74
- publish(input: ArtifactPublishInput, owner: ArtifactOwner): Promise<PublishedArtifact>;
74
+ publish(input: ArtifactPublishInput & {
75
+ fileName?: string;
76
+ }, owner: ArtifactOwner): Promise<PublishedArtifact>;
75
77
  /** Publishes a single text file the caller holds in memory. */
76
78
  publishText(input: {
77
79
  fileName: string;
@@ -11,6 +11,7 @@ import { basename, dirname, join, relative, resolve, sep } from "node:path";
11
11
  import { newId } from "../runtime/ids.js";
12
12
  import { inside, workspaceKey } from "../runtime/paths.js";
13
13
  import { writeZip } from "../runtime/zip.js";
14
+ import { chartWarnings } from "./chart-lint.js";
14
15
  import { classifyArtifact, isPreviewable } from "./kinds.js";
15
16
  export const DEFAULT_ARTIFACT_LIMITS = {
16
17
  maxFiles: 200,
@@ -21,6 +22,8 @@ export const DEFAULT_ARTIFACT_LIMITS = {
21
22
  export class ArtifactRejected extends Error {
22
23
  }
23
24
  const MAX_DEPTH = 8;
25
+ /** HTML larger than this is not scanned for chart mistakes. */
26
+ const MAX_LINT_BYTES = 8 * 1024 * 1024;
24
27
  const HEAD_BYTES = 8192;
25
28
  /** `title` → `[a-z0-9-]`, at most 48 characters (`artifact` when nothing remains). */
26
29
  export function slugify(title) {
@@ -200,6 +203,7 @@ export class ArtifactStore {
200
203
  source: resolve(input.source),
201
204
  ...(input.title ? { title: input.title } : {}),
202
205
  ...(input.entry ? { entry: input.entry } : {}),
206
+ ...(input.fileName ? { fileName: checkName(input.fileName) } : {}),
203
207
  },
204
208
  ], owner);
205
209
  return published;
@@ -380,19 +384,25 @@ export class ArtifactStore {
380
384
  kind = type.kind;
381
385
  mimeType = type.mimeType;
382
386
  fileName = only.path;
387
+ if (kind === "dashboard" && only.bytes <= MAX_LINT_BYTES)
388
+ for (const note of chartWarnings(await readFile(join(filesDir, only.path), "utf8")))
389
+ warnings.push(`${label}: ${note}`);
383
390
  }
384
391
  else {
385
392
  entry = plan.entry;
386
393
  const type = classifyArtifact(entry, await readHead(join(filesDir, ...entry.split("/"))));
387
394
  kind = type.kind;
388
395
  mimeType = type.mimeType;
389
- fileName = files.length > 1 ? `${baseName}.zip` : basename(entry);
396
+ fileName =
397
+ plan.candidate.fileName ?? (files.length > 1 ? `${baseName}.zip` : basename(entry));
390
398
  if (type.kind === "dashboard") {
391
399
  const html = await readFile(join(filesDir, ...entry.split("/")), "utf8");
392
400
  const entryDir = entry.includes("/") ? entry.slice(0, entry.lastIndexOf("/")) : "";
393
401
  const missing = missingReferences(html, entryDir, new Set(files.map((f) => f.path)));
394
402
  if (missing.length)
395
403
  warnings.push(`${label}: ${entry} references missing files: ${missing.join(", ")}`);
404
+ for (const note of chartWarnings(html))
405
+ warnings.push(`${label}: ${entry} ${note}`);
396
406
  }
397
407
  }
398
408
  }
@@ -2,9 +2,55 @@
2
2
  export const CHANGELOG_DATA = {
3
3
  schema: 1,
4
4
  entries: [
5
+ {
6
+ version: "0.2.1",
7
+ date: "2026-10-02",
8
+ sections: [
9
+ {
10
+ title: "Fixed",
11
+ items: [
12
+ 'A run no longer stops with "reached its 600 s limit" while you read a plan or answer a question. The run time limit (`limits.timeoutMs`) now counts only active time: the time the agent waits for you (the plan review, a question, a tool approval) is not counted. A model or tool that really stalls still hits the limit. The same applies to the `goal.maxMinutes` cap of a session goal.',
13
+ 'A run that ended or was stopped during a plan review no longer leaves the next message failing with an "Uncertain tool outcome" error.',
14
+ "In the web, a plan review or a question can wait up to 60 minutes for your answer (tool approvals still expire after 10 minutes).",
15
+ 'The Settings label of `limits.timeoutMs` said "Provider request timeout"; it now says it is the run time limit.',
16
+ ],
17
+ },
18
+ ],
19
+ },
20
+ {
21
+ version: "0.2.0",
22
+ date: "2026-10-02",
23
+ sections: [
24
+ {
25
+ title: "Added",
26
+ items: [
27
+ "Plan diagrams: the plan agent can attach small Mermaid diagrams (flows, sequences, architecture) to the plan it proposes with `exit_plan`.",
28
+ "A plan viewer in the web UI: the plan opens in the artifact panel with its diagrams drawn in your theme, next to the plan review. The terminal shows the diagram source.",
29
+ "Plan files are saved in a plan folder with a `plan.json` manifest, so the plan and its diagrams are easy to find and reuse.",
30
+ "Two new settings, `plan.diagrams` and `plan.maxDiagrams` (in the configuration file, **Settings** in the web and `/settings` in the terminal), to turn diagrams off or cap how many a plan keeps.",
31
+ "Dashboard charts for Python analysis: `alisio_runtime.charts` builds interactive pie, donut, bar, line, area and scatter charts, KPI tiles and cards with an embedded Chart.js (MIT), so dashboards work offline, in the viewer and in the downloaded file.",
32
+ ],
33
+ },
34
+ {
35
+ title: "Improved",
36
+ items: [
37
+ "Charts adapt to their card and follow the light and dark theme, with value labels, tooltips and a data table for screen readers.",
38
+ "The pure-SVG helpers (`alisio_runtime.svg`) were rewritten: they scale with their container and carry a title and description.",
39
+ "The agent receives clearer guidance for `python_run` about how to draw charts and dashboards.",
40
+ ],
41
+ },
42
+ {
43
+ title: "Fixed",
44
+ items: [
45
+ "Pie charts drew wrong or broken slices in generated dashboards (including a single 100% slice), and bar charts rendered at a fixed small size.",
46
+ "A dashboard that draws SVG pies by hand, uses fixed-size SVG charts or loads a script from the network now gets a warning in the `python_run` result that points to the chart helpers.",
47
+ ],
48
+ },
49
+ ],
50
+ },
5
51
  {
6
52
  version: "0.1.1",
7
- date: "2026-10-03",
53
+ date: "2026-10-02",
8
54
  sections: [
9
55
  {
10
56
  title: "Fixed",
package/dist/config.d.ts CHANGED
@@ -231,6 +231,10 @@ export declare const configSchema: z.ZodPipe<z.ZodObject<{
231
231
  noToolTurnsLimit: z.ZodDefault<z.ZodNumber>;
232
232
  blockedRepeats: z.ZodDefault<z.ZodNumber>;
233
233
  }, z.core.$strict>>;
234
+ plan: z.ZodDefault<z.ZodObject<{
235
+ diagrams: z.ZodDefault<z.ZodBoolean>;
236
+ maxDiagrams: z.ZodDefault<z.ZodNumber>;
237
+ }, z.core.$strict>>;
234
238
  tui: z.ZodDefault<z.ZodObject<{
235
239
  paddingX: z.ZodDefault<z.ZodNumber>;
236
240
  contentPaddingX: z.ZodDefault<z.ZodNumber>;
@@ -338,6 +342,10 @@ export declare const configSchema: z.ZodPipe<z.ZodObject<{
338
342
  noToolTurnsLimit: number;
339
343
  blockedRepeats: number;
340
344
  };
345
+ plan: {
346
+ diagrams: boolean;
347
+ maxDiagrams: number;
348
+ };
341
349
  tui: {
342
350
  paddingX: number;
343
351
  contentPaddingX: number;
@@ -528,6 +536,10 @@ export declare const configSchema: z.ZodPipe<z.ZodObject<{
528
536
  noToolTurnsLimit: number;
529
537
  blockedRepeats: number;
530
538
  };
539
+ plan: {
540
+ diagrams: boolean;
541
+ maxDiagrams: number;
542
+ };
531
543
  tui: {
532
544
  paddingX: number;
533
545
  contentPaddingX: number;
@@ -643,6 +655,8 @@ declare const SETTABLE_KEYS: {
643
655
  readonly "goal.repeatedReplyLimit": z.ZodDefault<z.ZodNumber>;
644
656
  readonly "goal.noToolTurnsLimit": z.ZodDefault<z.ZodNumber>;
645
657
  readonly "goal.blockedRepeats": z.ZodDefault<z.ZodNumber>;
658
+ readonly "plan.diagrams": z.ZodDefault<z.ZodBoolean>;
659
+ readonly "plan.maxDiagrams": z.ZodDefault<z.ZodNumber>;
646
660
  };
647
661
  export type SettableSettingKey = keyof typeof SETTABLE_KEYS;
648
662
  export declare function isSettableSettingKey(key: string): key is SettableSettingKey;
package/dist/config.js CHANGED
@@ -386,6 +386,18 @@ const configObjectSchema = z
386
386
  noToolTurnsLimit: 3,
387
387
  blockedRepeats: 2,
388
388
  })),
389
+ /**
390
+ * Plan diagrams (`exit_plan`): the plan agent may add Mermaid diagrams to a plan. `diagrams`
391
+ * off removes them from the tool and from the agent's instructions; `maxDiagrams` caps how
392
+ * many one plan keeps (0 to 8). The size and node limits of one diagram are constants.
393
+ */
394
+ plan: z
395
+ .object({
396
+ diagrams: z.boolean().default(true),
397
+ maxDiagrams: z.number().int().min(0).max(8).default(5),
398
+ })
399
+ .strict()
400
+ .default(() => ({ diagrams: true, maxDiagrams: 5 })),
389
401
  tui: z
390
402
  .object({
391
403
  /** Horizontal padding (columns) around the editor input box. Editor-only. */
@@ -767,6 +779,7 @@ const SETTABLE_SECTIONS = {
767
779
  analysis: configObjectSchema.shape.analysis.removeDefault(),
768
780
  tasks: configObjectSchema.shape.tasks.removeDefault(),
769
781
  goal: configObjectSchema.shape.goal.removeDefault(),
782
+ plan: configObjectSchema.shape.plan.removeDefault(),
770
783
  };
771
784
  const ANALYSIS_LIMITS = SETTABLE_SECTIONS.analysis.shape.limits.removeDefault();
772
785
  const ANALYSIS_RETENTION = SETTABLE_SECTIONS.analysis.shape.retention.removeDefault();
@@ -807,6 +820,8 @@ const SETTABLE_KEYS = {
807
820
  "goal.repeatedReplyLimit": SETTABLE_SECTIONS.goal.shape.repeatedReplyLimit,
808
821
  "goal.noToolTurnsLimit": SETTABLE_SECTIONS.goal.shape.noToolTurnsLimit,
809
822
  "goal.blockedRepeats": SETTABLE_SECTIONS.goal.shape.blockedRepeats,
823
+ "plan.diagrams": SETTABLE_SECTIONS.plan.shape.diagrams,
824
+ "plan.maxDiagrams": SETTABLE_SECTIONS.plan.shape.maxDiagrams,
810
825
  };
811
826
  export function isSettableSettingKey(key) {
812
827
  return Object.prototype.hasOwnProperty.call(SETTABLE_KEYS, key);
@@ -1,4 +1,4 @@
1
- import type { AnalysisCapability, ArtifactPublisher, ArtifactRef, CompactionCheckpoint, InstallPreview, Message, ToolCall, ToolResult } from "@alisio/sdk";
1
+ import type { AnalysisCapability, ArtifactPublisher, ArtifactPublishInput, ArtifactRef, CompactionCheckpoint, InstallPreview, Message, ToolCall, ToolResult } from "@alisio/sdk";
2
2
  export interface Session {
3
3
  id: string;
4
4
  workspace: string;
@@ -255,6 +255,13 @@ export interface CoreArtifactPublisher extends ArtifactPublisher {
255
255
  partial?: boolean;
256
256
  provenance?: Record<string, unknown>;
257
257
  }): Promise<PublishedArtifactInfo[]>;
258
+ /**
259
+ * Detailed variant of `publish` (path and warnings). `fileName` names a multi-file folder with
260
+ * an `entry` after its main file (`plan.md`) instead of `<folder>.zip`; downloads are still ZIPs.
261
+ */
262
+ publishDetailed(input: ArtifactPublishInput & {
263
+ fileName?: string;
264
+ }): Promise<PublishedArtifactInfo>;
258
265
  /** Detailed variant of `publishText` (path and warnings). */
259
266
  publishTextDetailed(input: {
260
267
  fileName: string;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Human waits: the moments a run is blocked on a PERSON (a tool approval, an external-directory
3
+ * approval, `ask_user_question`, the plan review) and not on work. The run's time limit does not
4
+ * count them (see `RunClock`).
5
+ *
6
+ * One registry per application. Each active run registers its clock under its session; anything
7
+ * that asks a person goes through `track`, which pauses the clocks involved until the answer
8
+ * arrives. A child session (a subagent) pauses its own clock AND its ancestors': a parent that
9
+ * waits for a child is doing real work, but when that child waits for a person nobody works.
10
+ */
11
+ import type { RunClock } from "./run-clock.ts";
12
+ export declare class HumanWaits {
13
+ private readonly parentOf;
14
+ private clocks;
15
+ constructor(parentOf?: (session: string) => string | undefined);
16
+ /** A run began: its clock follows the waits of its session. Returns the unregister function. */
17
+ register(session: string, clock: RunClock): () => void;
18
+ /**
19
+ * Opens a wait for `session` (the session asking; its ancestors are paused too). Without a
20
+ * session every active run is paused: somebody is being asked and nobody can say who for.
21
+ */
22
+ begin(session?: string): () => void;
23
+ /** Runs `wait` as a human wait: the clocks are paused until it settles, however it settles. */
24
+ track<T>(session: string | undefined, wait: () => Promise<T>): Promise<T>;
25
+ }
@@ -0,0 +1,54 @@
1
+ export class HumanWaits {
2
+ parentOf;
3
+ clocks = new Map();
4
+ constructor(parentOf = () => undefined) {
5
+ this.parentOf = parentOf;
6
+ }
7
+ /** A run began: its clock follows the waits of its session. Returns the unregister function. */
8
+ register(session, clock) {
9
+ this.clocks.set(session, clock);
10
+ return () => {
11
+ if (this.clocks.get(session) === clock)
12
+ this.clocks.delete(session);
13
+ };
14
+ }
15
+ /**
16
+ * Opens a wait for `session` (the session asking; its ancestors are paused too). Without a
17
+ * session every active run is paused: somebody is being asked and nobody can say who for.
18
+ */
19
+ begin(session) {
20
+ const releases = [];
21
+ if (session === undefined)
22
+ for (const clock of this.clocks.values())
23
+ releases.push(clock.pause());
24
+ else {
25
+ const seen = new Set();
26
+ for (let id = session; id !== undefined && !seen.has(id);) {
27
+ seen.add(id);
28
+ const clock = this.clocks.get(id);
29
+ if (clock)
30
+ releases.push(clock.pause());
31
+ try {
32
+ id = this.parentOf(id);
33
+ }
34
+ catch {
35
+ id = undefined;
36
+ }
37
+ }
38
+ }
39
+ return () => {
40
+ for (const release of releases)
41
+ release();
42
+ };
43
+ }
44
+ /** Runs `wait` as a human wait: the clocks are paused until it settles, however it settles. */
45
+ async track(session, wait) {
46
+ const end = this.begin(session);
47
+ try {
48
+ return await wait();
49
+ }
50
+ finally {
51
+ end();
52
+ }
53
+ }
54
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The run's time limit (`limits.timeoutMs`) as a PAUSABLE clock.
3
+ *
4
+ * The limit exists to stop runaway work (a stuck model, a looping tool), not to cap how long a
5
+ * person takes to read a plan or answer a question. So the clock counts ACTIVE time only: while at
6
+ * least one human wait is open (an approval, a question, a plan review) it is paused, and it
7
+ * resumes with the remaining time when the last wait ends.
8
+ *
9
+ * Waits nest and overlap (several tools can wait for approvals in parallel), so pausing is
10
+ * counted: the clock runs again only when every pause was released. A pause is released exactly
11
+ * once (the function `pause()` returns is idempotent), and a released or disposed clock ignores
12
+ * late calls, so an aborted run can never leave a timer behind.
13
+ */
14
+ /** Time source and timers, replaceable in tests. */
15
+ export interface ClockSource {
16
+ now(): number;
17
+ setTimeout(fn: () => void, ms: number): unknown;
18
+ clearTimeout(handle: unknown): void;
19
+ }
20
+ export declare class RunClock {
21
+ readonly limitMs: number;
22
+ private readonly controller;
23
+ private readonly source;
24
+ private consumed;
25
+ /** Start of the current running stretch (undefined while paused or finished). */
26
+ private since;
27
+ private timer;
28
+ private pauses;
29
+ private done;
30
+ constructor(limitMs: number, source?: ClockSource);
31
+ /** Aborts with a `TimeoutError` (the same reason `AbortSignal.timeout` uses) at the limit. */
32
+ get signal(): AbortSignal;
33
+ /** Whether at least one human wait is open. */
34
+ get paused(): boolean;
35
+ /** Time the run was really working: elapsed time minus every paused stretch. */
36
+ activeMs(): number;
37
+ remainingMs(): number;
38
+ /**
39
+ * Opens a human wait. Returns the function that closes it (idempotent). Pausing a clock that
40
+ * already ended (limit reached or disposed) does nothing.
41
+ */
42
+ pause(): () => void;
43
+ /** Stops the clock for good and frees its timer (call when the run ends, whatever the reason). */
44
+ dispose(): void;
45
+ private arm;
46
+ private disarm;
47
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * The run's time limit (`limits.timeoutMs`) as a PAUSABLE clock.
3
+ *
4
+ * The limit exists to stop runaway work (a stuck model, a looping tool), not to cap how long a
5
+ * person takes to read a plan or answer a question. So the clock counts ACTIVE time only: while at
6
+ * least one human wait is open (an approval, a question, a plan review) it is paused, and it
7
+ * resumes with the remaining time when the last wait ends.
8
+ *
9
+ * Waits nest and overlap (several tools can wait for approvals in parallel), so pausing is
10
+ * counted: the clock runs again only when every pause was released. A pause is released exactly
11
+ * once (the function `pause()` returns is idempotent), and a released or disposed clock ignores
12
+ * late calls, so an aborted run can never leave a timer behind.
13
+ */
14
+ /** Longest delay a timer supports; a longer limit re-arms itself. */
15
+ const MAX_TIMER_MS = 2_147_483_647;
16
+ const realSource = {
17
+ now: () => Date.now(),
18
+ setTimeout: (fn, ms) => {
19
+ const timer = setTimeout(fn, ms);
20
+ // Like `AbortSignal.timeout`: the limit alone never keeps the process alive.
21
+ timer.unref?.();
22
+ return timer;
23
+ },
24
+ clearTimeout: (handle) => clearTimeout(handle),
25
+ };
26
+ export class RunClock {
27
+ limitMs;
28
+ controller = new AbortController();
29
+ source;
30
+ consumed = 0;
31
+ /** Start of the current running stretch (undefined while paused or finished). */
32
+ since;
33
+ timer;
34
+ pauses = 0;
35
+ done = false;
36
+ constructor(limitMs, source = realSource) {
37
+ this.limitMs = limitMs;
38
+ this.source = source;
39
+ this.since = source.now();
40
+ this.arm();
41
+ }
42
+ /** Aborts with a `TimeoutError` (the same reason `AbortSignal.timeout` uses) at the limit. */
43
+ get signal() {
44
+ return this.controller.signal;
45
+ }
46
+ /** Whether at least one human wait is open. */
47
+ get paused() {
48
+ return this.pauses > 0;
49
+ }
50
+ /** Time the run was really working: elapsed time minus every paused stretch. */
51
+ activeMs() {
52
+ return (this.consumed + (this.since === undefined ? 0 : Math.max(0, this.source.now() - this.since)));
53
+ }
54
+ remainingMs() {
55
+ return Math.max(0, this.limitMs - this.activeMs());
56
+ }
57
+ /**
58
+ * Opens a human wait. Returns the function that closes it (idempotent). Pausing a clock that
59
+ * already ended (limit reached or disposed) does nothing.
60
+ */
61
+ pause() {
62
+ if (this.done)
63
+ return () => { };
64
+ if (this.pauses++ === 0) {
65
+ if (this.since !== undefined)
66
+ this.consumed += Math.max(0, this.source.now() - this.since);
67
+ this.since = undefined;
68
+ this.disarm();
69
+ }
70
+ let released = false;
71
+ return () => {
72
+ if (released)
73
+ return;
74
+ released = true;
75
+ if (this.done)
76
+ return;
77
+ if (--this.pauses === 0) {
78
+ this.since = this.source.now();
79
+ this.arm();
80
+ }
81
+ };
82
+ }
83
+ /** Stops the clock for good and frees its timer (call when the run ends, whatever the reason). */
84
+ dispose() {
85
+ if (this.done)
86
+ return;
87
+ this.consumed = this.activeMs();
88
+ this.done = true;
89
+ this.since = undefined;
90
+ this.disarm();
91
+ }
92
+ arm() {
93
+ this.disarm();
94
+ this.timer = this.source.setTimeout(() => {
95
+ this.timer = undefined;
96
+ if (this.done || this.pauses > 0)
97
+ return;
98
+ if (this.remainingMs() > 0)
99
+ return this.arm();
100
+ this.consumed = this.activeMs();
101
+ this.done = true;
102
+ this.since = undefined;
103
+ this.controller.abort(new DOMException("The operation timed out.", "TimeoutError"));
104
+ }, Math.min(this.remainingMs(), MAX_TIMER_MS));
105
+ }
106
+ disarm() {
107
+ if (this.timer !== undefined)
108
+ this.source.clearTimeout(this.timer);
109
+ this.timer = undefined;
110
+ }
111
+ }
@@ -11,6 +11,8 @@ export interface RunStats {
11
11
  output: number;
12
12
  };
13
13
  toolCalls: number;
14
+ /** Active time of the run (waits for a person excluded). */
15
+ activeMs?: number;
14
16
  }
15
17
  export declare function attachRunStats(error: unknown, stats: RunStats): void;
16
18
  export declare function runStatsOf(error: unknown): RunStats | undefined;