@alisio/core 0.2.0 → 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
  });
@@ -730,7 +745,9 @@ export async function createApplication(options = {}) {
730
745
  : [];
731
746
  return declared.length ? Math.min(...declared) : undefined;
732
747
  };
748
+ plugins.setHumanWaits(humanWaits);
733
749
  const runner = new AgentRunner({
750
+ humanWaits,
734
751
  provider,
735
752
  providerFor,
736
753
  registry,
@@ -2,6 +2,21 @@
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
+ },
5
20
  {
6
21
  version: "0.2.0",
7
22
  date: "2026-10-02",
@@ -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;
@@ -1,6 +1,7 @@
1
1
  import { type Attachment, type DatasetRef, type Message, type ModelProvider, type RunEvent, type ToolDefinition, type ToolResult } from "@alisio/sdk";
2
2
  import type { PathAccess } from "../runtime/access.ts";
3
3
  import type { ApprovalHandler, ArtifactPublisherFactory, CapabilityGate, ContextSource, Policy, RunnerExtensions, SessionStore } from "./contracts.ts";
4
+ import { HumanWaits } from "./human-wait.ts";
4
5
  import type { ToolRegistry } from "./registry.ts";
5
6
  export interface CompactionSettings {
6
7
  /** Compact automatically when context usage crosses `threshold` of a known window. */
@@ -62,6 +63,13 @@ export interface RunnerOptions {
62
63
  compaction?: CompactionSettings;
63
64
  /** When set, write/process tools not allowed by the policy are offered and ask first. */
64
65
  approve?: ApprovalHandler;
66
+ /**
67
+ * Registry of the waits for a person (approvals, questions, the plan review): the run's time
68
+ * limit does not count them. Share one with the plugin host and every other asking surface so a
69
+ * wait outside the runner (for example `ask_user_question`) pauses the same clocks. Without it
70
+ * the runner uses a private one, which still covers every approval it asks itself.
71
+ */
72
+ humanWaits?: HumanWaits;
65
73
  /**
66
74
  * Mediated path policy (workspace plus declared extra directories and external approval).
67
75
  * When set, declared tool paths outside the allowed roots ask before the effect gate runs.
@@ -179,6 +187,8 @@ export declare class AgentRunner {
179
187
  private inbox;
180
188
  /** Last provider-reported context size per session and the history length it covered. */
181
189
  private reported;
190
+ /** Where human waits are opened; the clocks of the active runs listen to it. */
191
+ readonly humanWaits: HumanWaits;
182
192
  constructor(options: RunnerOptions);
183
193
  /** Current capability policy (may widen when the user approves an effect for the session). */
184
194
  get policy(): Readonly<Policy>;
@@ -284,5 +294,7 @@ export declare class AgentRunner {
284
294
  };
285
295
  /** Tool calls the model made during the run (the goal controller's no-progress breaker). */
286
296
  toolCalls: number;
297
+ /** Time the run was really working: wall time minus the waits for a person. */
298
+ activeMs: number;
287
299
  }>;
288
300
  }
@@ -1,7 +1,9 @@
1
1
  import { isEphemeralRunEventType, textProjection, textResult, } from "@alisio/sdk";
2
2
  import { checkpointInstructions, estimateTokens, MAX_TRUSTED_WINDOW, parseCheckpointOutput, planCompaction, reduceMessagesToBudget, shouldCompactContext, summarize, summaryMessage, } from "./compaction.js";
3
+ import { HumanWaits } from "./human-wait.js";
3
4
  import { EXIT_PLAN_TOOL, optInAllows } from "./opt-in.js";
4
5
  import { resolveMaxOutputTokens } from "./output-limit.js";
6
+ import { RunClock } from "./run-clock.js";
5
7
  import { attachRunStats } from "./run-stats.js";
6
8
  import { isTimeoutReason, providerLabel, RunTimeoutError } from "./timeout.js";
7
9
  import { incompleteCalls, isOutputTruncationError, lowerEffort, RunTruncationError, TRUNCATION_NOTICE, } from "./truncation.js";
@@ -60,8 +62,20 @@ export class AgentRunner {
60
62
  inbox = new Map();
61
63
  /** Last provider-reported context size per session and the history length it covered. */
62
64
  reported = new Map();
65
+ /** Where human waits are opened; the clocks of the active runs listen to it. */
66
+ humanWaits;
63
67
  constructor(options) {
64
68
  this.options = options;
69
+ this.humanWaits =
70
+ options.humanWaits ??
71
+ new HumanWaits((session) => {
72
+ try {
73
+ return options.store.get(session).parentId ?? undefined;
74
+ }
75
+ catch {
76
+ return undefined;
77
+ }
78
+ });
65
79
  }
66
80
  /** Current capability policy (may widen when the user approves an effect for the session). */
67
81
  get policy() {
@@ -465,17 +479,20 @@ export class AgentRunner {
465
479
  ...(options.label ? { label: options.label } : {}),
466
480
  ...(capability ? { capability } : {}),
467
481
  });
468
- const decision = await o.approve({
482
+ const approve = o.approve;
483
+ const callInput = p.input;
484
+ // Waiting for the person is not work: the run's time limit does not count it.
485
+ const decision = await this.humanWaits.track(sessionId, () => approve({
469
486
  call,
470
487
  effect,
471
- input: p.input,
488
+ input: callInput,
472
489
  signal: combined,
473
490
  session: sessionId,
474
491
  ...(options.label ? { label: options.label } : {}),
475
492
  ...(capability ? { capability } : {}),
476
493
  ...(preview !== undefined ? { preview } : {}),
477
494
  ...(capability && gate?.runtime ? { runtime: gate.runtime } : {}),
478
- });
495
+ }));
479
496
  combined.throwIfAborted();
480
497
  const persisted = !!capability && !!gate && decision === "session";
481
498
  if (capability && gate)
@@ -530,7 +547,8 @@ export class AgentRunner {
530
547
  });
531
548
  let decision = "deny";
532
549
  try {
533
- decision = await o.approve({
550
+ const approve = o.approve;
551
+ decision = await this.humanWaits.track(sessionId, () => approve({
534
552
  call: installCall,
535
553
  effect: "process",
536
554
  input: { extras: install.extras },
@@ -539,7 +557,7 @@ export class AgentRunner {
539
557
  ...(options.label ? { label: options.label } : {}),
540
558
  capability: "analysis.install",
541
559
  install,
542
- });
560
+ }));
543
561
  }
544
562
  finally {
545
563
  combined.throwIfAborted();
@@ -607,8 +625,12 @@ export class AgentRunner {
607
625
  }
608
626
  catch (error) {
609
627
  result = textResult(error instanceof Error ? error.message : String(error), true);
610
- // A cancellation after execution may have left effects; journal remains pending.
611
- if (!combined.aborted)
628
+ // A cancellation after execution may have left effects, so the journal stays pending for
629
+ // the user to inspect. A `read` call cannot have left any (and `exit_plan`, which is one,
630
+ // is exactly what a stop or the run limit interrupts while a person decides): it is closed
631
+ // with the cancellation, so the next message continues normally instead of asking to
632
+ // "recover" a call nobody needs to inspect.
633
+ if (!combined.aborted || p.tool?.effect === "read")
612
634
  o.store.endCall(sessionId, call, result);
613
635
  }
614
636
  emit("tool_completed", {
@@ -649,10 +671,11 @@ export class AgentRunner {
649
671
  }
650
672
  };
651
673
  const runTimeoutMs = options.timeoutMs ?? o.timeoutMs ?? 300_000;
652
- const timeout = AbortSignal.timeout(runTimeoutMs);
674
+ const clock = new RunClock(runTimeoutMs);
675
+ const unregisterClock = this.humanWaits.register(sessionId, clock);
653
676
  const combined = AbortSignal.any([
654
677
  controller.signal,
655
- timeout,
678
+ clock.signal,
656
679
  ...(options.signal ? [options.signal] : []),
657
680
  ]);
658
681
  const workspace = options.workspace ?? o.workspace;
@@ -730,6 +753,8 @@ export class AgentRunner {
730
753
  throw error;
731
754
  }
732
755
  finally {
756
+ unregisterClock();
757
+ clock.dispose();
733
758
  if (acquired)
734
759
  o.store.release(sessionId);
735
760
  this.active.delete(sessionId);
@@ -760,9 +785,14 @@ export class AgentRunner {
760
785
  /** Continuation notice and lowered effort for the request that follows a cut-off response. */
761
786
  let recoveryNotice;
762
787
  let recoveryEffort;
763
- const runStartedAt = Date.now();
764
- const timeout = AbortSignal.timeout(runTimeoutMs);
765
- const combined = AbortSignal.any([controller.signal, timeout, ...(signal ? [signal] : [])]);
788
+ // Active time only: waits for a person pause it (see `RunClock`).
789
+ const clock = new RunClock(runTimeoutMs);
790
+ const unregisterClock = this.humanWaits.register(sessionId, clock);
791
+ const combined = AbortSignal.any([
792
+ controller.signal,
793
+ clock.signal,
794
+ ...(signal ? [signal] : []),
795
+ ]);
766
796
  // What the run was doing, so a timeout can say where it was stuck (see `RunTimeoutError`).
767
797
  let stage = "other";
768
798
  let stageTool;
@@ -1006,7 +1036,7 @@ export class AgentRunner {
1006
1036
  }
1007
1037
  // Do not start an attempt the whole-run limit could not let finish.
1008
1038
  const retryPauseMs = FIRST_TOKEN_RETRY_PAUSE_MS;
1009
- if (runTimeoutMs - (Date.now() - runStartedAt) < retryPauseMs + firstTokenMs) {
1039
+ if (clock.remainingMs() < retryPauseMs + firstTokenMs) {
1010
1040
  if (failure !== undefined)
1011
1041
  throw failure;
1012
1042
  break;
@@ -1118,7 +1148,14 @@ export class AgentRunner {
1118
1148
  }
1119
1149
  emit("run_completed", final);
1120
1150
  finish("completed");
1121
- return { sessionId, text: lastText, status: "completed", usage: usageTotal, toolCalls };
1151
+ return {
1152
+ sessionId,
1153
+ text: lastText,
1154
+ status: "completed",
1155
+ usage: usageTotal,
1156
+ toolCalls,
1157
+ activeMs: clock.activeMs(),
1158
+ };
1122
1159
  }
1123
1160
  toolCalls += completion.calls.length;
1124
1161
  const prepared = completion.calls.map((call) => {
@@ -1209,7 +1246,14 @@ export class AgentRunner {
1209
1246
  // token budget (`maxTokens`) and the timeout; the turn count is a safety rail.
1210
1247
  emit("run_turns_exceeded", { turns: maxTurns, maxTurns });
1211
1248
  finish("turns_exceeded");
1212
- return { sessionId, text: lastText, status: "turns-exceeded", usage: usageTotal, toolCalls };
1249
+ return {
1250
+ sessionId,
1251
+ text: lastText,
1252
+ status: "turns-exceeded",
1253
+ usage: usageTotal,
1254
+ toolCalls,
1255
+ activeMs: clock.activeMs(),
1256
+ };
1213
1257
  }
1214
1258
  catch (error) {
1215
1259
  const timedOut = silentTimedOut
@@ -1228,7 +1272,11 @@ export class AgentRunner {
1228
1272
  });
1229
1273
  emit("run_failed", { error: failure.message, code: "timeout", timeout: failure.info });
1230
1274
  finish("failed", failure.message);
1231
- attachRunStats(failure, { usage: { ...usageTotal }, toolCalls });
1275
+ attachRunStats(failure, {
1276
+ usage: { ...usageTotal },
1277
+ toolCalls,
1278
+ activeMs: clock.activeMs(),
1279
+ });
1232
1280
  throw failure;
1233
1281
  }
1234
1282
  if (error instanceof RunTruncationError) {
@@ -1238,7 +1286,7 @@ export class AgentRunner {
1238
1286
  truncation: error.info,
1239
1287
  });
1240
1288
  finish("failed", error.message);
1241
- attachRunStats(error, { usage: { ...usageTotal }, toolCalls });
1289
+ attachRunStats(error, { usage: { ...usageTotal }, toolCalls, activeMs: clock.activeMs() });
1242
1290
  throw error;
1243
1291
  }
1244
1292
  // On cancellation report the abort reason, not the transport's secondary error.
@@ -1246,10 +1294,12 @@ export class AgentRunner {
1246
1294
  const message = cause instanceof Error ? cause.message : String(cause);
1247
1295
  emit(combined.aborted ? "run_cancelled" : "run_failed", { error: message });
1248
1296
  finish(combined.aborted ? "cancelled" : "failed", message);
1249
- attachRunStats(error, { usage: { ...usageTotal }, toolCalls });
1297
+ attachRunStats(error, { usage: { ...usageTotal }, toolCalls, activeMs: clock.activeMs() });
1250
1298
  throw error;
1251
1299
  }
1252
1300
  finally {
1301
+ unregisterClock();
1302
+ clock.dispose();
1253
1303
  if (acquired)
1254
1304
  o.store.release(sessionId);
1255
1305
  this.active.delete(sessionId);
@@ -48,7 +48,7 @@ export function describeTimeout(info) {
48
48
  return info.firstRequest
49
49
  ? `${who} did not respond within ${limit}: no tokens were received. ` +
50
50
  "Retry, switch to another model, or raise limits.timeoutMs."
51
- : `The run reached its ${limit} limit while waiting for ${info.model ? `the model ${info.model}` : "the model"}` +
51
+ : `The run reached its limit of ${limit} of active time (time spent waiting for you is not counted) while waiting for ${info.model ? `the model ${info.model}` : "the model"}` +
52
52
  `${info.provider ? ` (${info.provider})` : ""}. ` +
53
53
  "Everything produced so far is kept: prompt again to continue, switch model, or raise limits.timeoutMs.";
54
54
  const doing = info.stage === "tool" && info.tool
@@ -56,6 +56,6 @@ export function describeTimeout(info) {
56
56
  : info.stage === "streaming"
57
57
  ? "while the model was answering"
58
58
  : "";
59
- return (`The run reached its ${limit} limit${doing ? ` ${doing}` : ""}. ` +
59
+ return (`The run reached its limit of ${limit} of active time (time spent waiting for you is not counted)${doing ? ` ${doing}` : ""}. ` +
60
60
  "Everything produced so far is kept: prompt again to continue, or raise limits.timeoutMs.");
61
61
  }
@@ -8,6 +8,8 @@ export interface RunSummary {
8
8
  output: number;
9
9
  };
10
10
  toolCalls?: number;
11
+ /** Active time of the run: waits for a person are not counted. */
12
+ activeMs?: number;
11
13
  }
12
14
  /**
13
15
  * The outcome of one run. `cancelled` is whether the run's abort signal fired (the user stopped
package/dist/goal/host.js CHANGED
@@ -30,7 +30,8 @@ export function goalOutcome(input) {
30
30
  text: result?.text ?? "",
31
31
  toolCalls: result?.toolCalls ?? stats?.toolCalls ?? 0,
32
32
  startedAt: input.startedAt,
33
- durationMs: Math.max(0, input.endedAt - input.startedAt),
33
+ // `goal.maxMinutes` counts the time a run was working, never the time it waited for you.
34
+ durationMs: Math.max(0, Math.min(input.endedAt - input.startedAt, result?.activeMs ?? stats?.activeMs ?? Number.POSITIVE_INFINITY)),
34
35
  };
35
36
  }
36
37
  /**
package/dist/index.d.ts CHANGED
@@ -34,9 +34,11 @@ export { BUILTIN_COMMANDS, type BuiltinCommand, CommandCatalog, type CommandExec
34
34
  export { type Config, type ConfigLoadResult, type ConfigProvenance, configFile, configHome, configSchema, isSettableSettingKey, type LoadedConfig, loadConfig, loadConfigWithProvenance, type McpServerSource, OCI_IMAGE_PATTERN, overridesSavedProviderProfile, type SettableSettingInfo, type SettableSettingKey, setConfigValue, setGlobalMcpAllow, setMcpServerEnabled, setProjectPluginEnabled, setProjectSkillEnabled, settableSettings, stateHome, } from "./config.ts";
35
35
  export { type ContextBudget, checkpointInstructions, completeText, effectiveContextBudget, estimateTokens, MAX_TRUSTED_WINDOW, parseCheckpointOutput, planCompaction, renderCheckpoint, serializeForSummary, shouldCompact, shouldCompactContext, summarize, } from "./core/compaction.ts";
36
36
  export type { ApprovalDecision, ApprovalHandler, ApprovalRequest, ArtifactPublisherFactory, BeginRunInput, CapabilityGate, ContextSource, CoreArtifactPublisher, EndRunInput, EventPage, HookFailure, MessagePage, PageOptions, Policy, PublishedArtifactInfo, RunnerExtensions, RunRecord, RunStatus, Session, SessionStore, StoredEvent, TerminalRunStatus, ToolCallMeta, } from "./core/contracts.ts";
37
+ export { HumanWaits } from "./core/human-wait.ts";
37
38
  export { EXIT_PLAN_TOOL, GOAL_TOOLS, OPT_IN_TOOLS } from "./core/opt-in.ts";
38
39
  export { DEFAULT_MAX_OUTPUT_TOKENS, describeOutputLimitSource, MAX_AUTO_OUTPUT_TOKENS, type OutputLimit, type OutputLimitSource, resolveMaxOutputTokens, } from "./core/output-limit.ts";
39
40
  export { ToolRegistry } from "./core/registry.ts";
41
+ export { RunClock } from "./core/run-clock.ts";
40
42
  export { AgentRunner, type CompactionResult, defaultTokenBudget, type RunnerOptions, type RunnerSettingsPatch, type RunOptions, } from "./core/runner.ts";
41
43
  export { type ExtensionConflict, ExtensionRegistry } from "./extensions/registry.ts";
42
44
  export * from "./goal/index.ts";
package/dist/index.js CHANGED
@@ -33,9 +33,11 @@ export { changelogNews, compareVersions, formatChangelogMarkdown, isVersion, loa
33
33
  export { BUILTIN_COMMANDS, CommandCatalog, } from "./commands/catalog.js";
34
34
  export { configFile, configHome, configSchema, isSettableSettingKey, loadConfig, loadConfigWithProvenance, OCI_IMAGE_PATTERN, overridesSavedProviderProfile, setConfigValue, setGlobalMcpAllow, setMcpServerEnabled, setProjectPluginEnabled, setProjectSkillEnabled, settableSettings, stateHome, } from "./config.js";
35
35
  export { checkpointInstructions, completeText, effectiveContextBudget, estimateTokens, MAX_TRUSTED_WINDOW, parseCheckpointOutput, planCompaction, renderCheckpoint, serializeForSummary, shouldCompact, shouldCompactContext, summarize, } from "./core/compaction.js";
36
+ export { HumanWaits } from "./core/human-wait.js";
36
37
  export { EXIT_PLAN_TOOL, GOAL_TOOLS, OPT_IN_TOOLS } from "./core/opt-in.js";
37
38
  export { DEFAULT_MAX_OUTPUT_TOKENS, describeOutputLimitSource, MAX_AUTO_OUTPUT_TOKENS, resolveMaxOutputTokens, } from "./core/output-limit.js";
38
39
  export { ToolRegistry } from "./core/registry.js";
40
+ export { RunClock } from "./core/run-clock.js";
39
41
  export { AgentRunner, defaultTokenBudget, } from "./core/runner.js";
40
42
  export { ExtensionRegistry } from "./extensions/registry.js";
41
43
  export * from "./goal/index.js";
@@ -1,5 +1,6 @@
1
1
  import type { AskQuestionsRequest, AskQuestionsResult, CommandContext, CommandOptions, CompletionRequest, Message, PanelProvider, Plugin, PluginAPI, PluginMetadata, RunEvent, SelectRequest, ViewContext } from "@alisio/sdk";
2
2
  import type { HookFailure, RunnerExtensions } from "../core/contracts.ts";
3
+ import type { HumanWaits } from "../core/human-wait.ts";
3
4
  import type { ToolRegistry } from "../core/registry.ts";
4
5
  import { ExtensionRegistry } from "../extensions/registry.ts";
5
6
  import type { ProviderRegistry } from "../providers/registry.ts";
@@ -83,7 +84,15 @@ export declare class PluginHost implements RunnerExtensions {
83
84
  private uiImpl?;
84
85
  /** Binds the child session service once the runner exists. */
85
86
  setSessions(sessions: PluginAPI["sessions"]): void;
86
- /** Binds interactive UI services (the TUI); without them select/askQuestions resolve undefined. */
87
+ /** Where waits for a person are opened (see `HumanWaits`); bound once the runner exists. */
88
+ private humanWaits?;
89
+ setHumanWaits(waits: HumanWaits): void;
90
+ /**
91
+ * Binds interactive UI services (the TUI, the web server); without them select/askQuestions
92
+ * resolve undefined. Every question that reaches the person through here (plugins, the
93
+ * `ask_user_question` tool, the plan review) is a human wait: the bound UI is wrapped ONCE so no
94
+ * host and no caller can forget to mark it.
95
+ */
87
96
  setInteractiveUI(ui: {
88
97
  select(request: SelectRequest): Promise<string | undefined>;
89
98
  askQuestions(request: AskQuestionsRequest): Promise<AskQuestionsResult>;
@@ -85,9 +85,24 @@ export class PluginHost {
85
85
  setSessions(sessions) {
86
86
  this.sessionsImpl = sessions;
87
87
  }
88
- /** Binds interactive UI services (the TUI); without them select/askQuestions resolve undefined. */
88
+ /** Where waits for a person are opened (see `HumanWaits`); bound once the runner exists. */
89
+ humanWaits;
90
+ setHumanWaits(waits) {
91
+ this.humanWaits = waits;
92
+ }
93
+ /**
94
+ * Binds interactive UI services (the TUI, the web server); without them select/askQuestions
95
+ * resolve undefined. Every question that reaches the person through here (plugins, the
96
+ * `ask_user_question` tool, the plan review) is a human wait: the bound UI is wrapped ONCE so no
97
+ * host and no caller can forget to mark it.
98
+ */
89
99
  setInteractiveUI(ui) {
90
- this.uiImpl = ui;
100
+ const wait = (session, ask) => this.humanWaits ? this.humanWaits.track(session, ask) : ask();
101
+ this.uiImpl = {
102
+ select: (request) => wait(undefined, () => ui.select(request)),
103
+ askQuestions: (request) => wait(request.session, () => ui.askQuestions(request)),
104
+ open: (sessionId) => ui.open(sessionId),
105
+ };
91
106
  }
92
107
  /**
93
108
  * Read-only view of one plugin's shared state (written through `api.state.set`). The host layer
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alisio/core",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Embeddable provider-agnostic Alisio agent core: tool loop, context compaction, local tools, MCP, plugin host and SQLite session store. Runs on Node >=22.16 and Bun.",
5
5
  "author": "Gustavo Gutiérrez",
6
6
  "license": "MIT",
@@ -41,7 +41,7 @@
41
41
  "node": ">=22.16"
42
42
  },
43
43
  "dependencies": {
44
- "@alisio/sdk": "0.2.0",
44
+ "@alisio/sdk": "0.2.1",
45
45
  "@mixmark-io/domino": "2.2.0",
46
46
  "@modelcontextprotocol/client": "2.1.0",
47
47
  "ajv": "8.20.0",