@vincemakes/kiso-runtime 0.45.3 → 0.46.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/agent.d.ts CHANGED
@@ -34,6 +34,12 @@ export interface AgentDefinition {
34
34
  /** 0.40.0: the config profile that named this binding, recorded per
35
35
  * revision for display. Never part of drift. */
36
36
  readonly profileName?: string;
37
+ /** B1: a NEW session's initial reasoning (the CLI passes the effort last
38
+ * picked for the profile). Recorded in the session's first profile
39
+ * revision; never read for a session that has a recorded profile — its
40
+ * own wins, and a changed binding resets to defaults as before.
41
+ * `setModelBinding` stays the one way to change a live binding. */
42
+ readonly reasoning?: import("./provider/metadata.js").ReasoningSetting;
37
43
  /** `Tool<any>` like the registry: typed tools register without casts. */
38
44
  readonly tools: readonly Tool<any>[];
39
45
  readonly store: SessionStore;
package/dist/agent.js CHANGED
@@ -155,7 +155,11 @@ export class AgentRuntime {
155
155
  ...((restored !== null ? restored.scope : startupScope) !== undefined
156
156
  ? { continuationScope: (restored !== null ? restored.scope : startupScope) }
157
157
  : {}),
158
- ...(restored !== null ? { reasoning: restored.reasoning } : {}),
158
+ ...(restored !== null
159
+ ? { reasoning: restored.reasoning }
160
+ : newSession !== null && this.#definition.reasoning !== undefined
161
+ ? { reasoning: this.#definition.reasoning }
162
+ : {}),
159
163
  ...(profilePending ? { profilePending: true } : {}),
160
164
  ...(newSession !== null ? { newSession } : {}),
161
165
  ...(this.#definition.profileName !== undefined ? { profileName: this.#definition.profileName } : {}),
@@ -31,3 +31,5 @@ export * from "./checkpoint.js";
31
31
  export * from "./request-budget.js";
32
32
  export { readProfile, readSummary, writeSummary, type ExecutionProfile, type ProfileReadResult } from "./profile.js";
33
33
  export { SUMMARY_MIGRATION_MARKER, listSessionSidecars, migrateSummaries, sessionSummary, summarizeEvents, summaryMigrationPending, type SessionListing, type SessionSummary } from "./session-summary.js";
34
+ export * from "./tasks/journal.js";
35
+ export * from "./tasks/manager.js";
package/dist/internal.js CHANGED
@@ -40,3 +40,7 @@ export * from "./request-budget.js";
40
40
  // 0.40.0: the listing reads the recorded workspace and profile name.
41
41
  export { readProfile, readSummary, writeSummary } from "./profile.js";
42
42
  export { SUMMARY_MIGRATION_MARKER, listSessionSidecars, migrateSummaries, sessionSummary, summarizeEvents, summaryMigrationPending } from "./session-summary.js";
43
+ // ADR-0058 §6: the task journal and the TaskManager (internal until the
44
+ // public shape is proven by the shell and delegate steps)
45
+ export * from "./tasks/journal.js";
46
+ export * from "./tasks/manager.js";
package/dist/profile.js CHANGED
@@ -90,7 +90,11 @@ function writeTenants(root, sessionId, tenants) {
90
90
  // this file comes into existence. Existing files are not migrated —
91
91
  // R6's choice, kept.
92
92
  writeFileSync(tmp, `${JSON.stringify(tenants, null, "\t")}\n`, { mode: 0o600 });
93
- const fd = openSync(tmp, "r");
93
+ // Windows: only a write-capable handle can be flushed, and no
94
+ // directory opens as a file — the rename is not flushed there (a
95
+ // process crash loses nothing; the power-loss contract is P6's)
96
+ const windows = process.platform === "win32";
97
+ const fd = openSync(tmp, windows ? "r+" : "r");
94
98
  try {
95
99
  fsyncSync(fd);
96
100
  }
@@ -98,12 +102,14 @@ function writeTenants(root, sessionId, tenants) {
98
102
  closeSync(fd);
99
103
  }
100
104
  renameSync(tmp, path);
101
- const dirFd = openSync(dirname(path), "r");
102
- try {
103
- fsyncSync(dirFd);
104
- }
105
- finally {
106
- closeSync(dirFd);
105
+ if (!windows) {
106
+ const dirFd = openSync(dirname(path), "r");
107
+ try {
108
+ fsyncSync(dirFd);
109
+ }
110
+ finally {
111
+ closeSync(dirFd);
112
+ }
107
113
  }
108
114
  }
109
115
  finally {
package/dist/run.d.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * persistence, the loop drive, the durable recovery state machine), moved
4
4
  * verbatim from session.ts.
5
5
  */
6
- import { type AbortSignalLike, type Adapter, type ContentBlock, type Event } from "@vincemakes/kiso-core";
6
+ import { type AbortSignalLike, type TaskDeliveryItem, type Adapter, type ContentBlock, type Event } from "@vincemakes/kiso-core";
7
7
  import type { SessionStore } from "./store.js";
8
8
  import { type AgentSession, type SessionConfig } from "./session.js";
9
9
  /**
@@ -21,6 +21,14 @@ export declare class RunClosedError extends Error {
21
21
  * natural shape; the handle also carries the runId, the abort and the
22
22
  * steer.
23
23
  */
24
+ /** ADR-0058 (3c): a task delivery handed to a run — one line per task
25
+ * (the wire marking) and the transitions it carries (the receipt). */
26
+ export interface TaskNotice {
27
+ readonly lines: readonly string[];
28
+ readonly items: readonly TaskDeliveryItem[];
29
+ }
30
+ /** The runtime's words, marked so the model never takes them for a person's. */
31
+ export declare const NOTICE_FOOTER = "Runtime notice \u2014 not the user.";
24
32
  export declare class Run implements AsyncIterable<Event> {
25
33
  #private;
26
34
  runId: string;
@@ -34,9 +42,20 @@ export declare class Run implements AsyncIterable<Event> {
34
42
  * {@link RunClosedError} once the run has decided to end.
35
43
  */
36
44
  steer(content: string | readonly ContentBlock[]): void;
37
- /** ADR-0057 — take back the input that has not been admitted yet. */
45
+ /** ADR-0058 (3c) — hand this run a task delivery. It is admitted at the
46
+ * next Safe Admission boundary, in arrival order with the person's
47
+ * steers, as a `source: "system"` user_input whose `via` is the receipt;
48
+ * it never passes onUserMessage. False once the run has decided to end
49
+ * — the caller keeps the notice for the next run or a wake. */
50
+ notify(notice: TaskNotice): boolean;
51
+ /** ADR-0057 — take back the person's input that has not been admitted
52
+ * yet. A task notice is not the person's, and stays. */
38
53
  retract(): readonly (string | readonly ContentBlock[])[];
39
- /** ADR-0057 — input that arrived but was not admitted when the run ended. */
54
+ /** ADR-0057 — the person's input that arrived but was not admitted when
55
+ * the run ended. */
40
56
  unadmitted(): readonly (string | readonly ContentBlock[])[];
57
+ /** ADR-0058 (3c) — task notices still pending when the run ended: the
58
+ * delivery takes them back, never lost. */
59
+ unadmittedNotices(): readonly TaskNotice[];
41
60
  [Symbol.asyncIterator](): AsyncIterator<Event>;
42
61
  }
package/dist/run.js CHANGED
@@ -26,13 +26,8 @@ export class RunClosedError extends Error {
26
26
  this.runId = runId;
27
27
  }
28
28
  }
29
- /**
30
- * A single foreground execution, from its input to one terminal (ADR-0057:
31
- * it may admit further input at Safe Admission boundaries).
32
- * Async-iterable, so `for await (const ev of session.run(x))` is the
33
- * natural shape; the handle also carries the runId, the abort and the
34
- * steer.
35
- */
29
+ /** The runtime's words, marked so the model never takes them for a person's. */
30
+ export const NOTICE_FOOTER = "Runtime notice — not the user.";
36
31
  export class Run {
37
32
  runId;
38
33
  #store;
@@ -51,11 +46,14 @@ export class Run {
51
46
  #externalSignal;
52
47
  #decisionIds = [];
53
48
  #started = false;
54
- // ADR-0057 — the ephemeral ingress: a person's input in arrival order,
55
- // held in memory until an admission site takes it. The log owns what was
56
- // admitted; nothing here is durable (a crash before admission loses it).
57
- #pending = [];
49
+ // ADR-0057 — the ephemeral ingress: a person's input AND the runtime's
50
+ // task notices (ADR-0058 3c), ONE queue in arrival order, held in memory
51
+ // until an admission site takes it. The log owns what was admitted;
52
+ // nothing here is durable (a crash before admission loses it — a task
53
+ // notice is re-derived from the journal and the log's receipts).
54
+ #ingress = [];
58
55
  #unadmitted = [];
56
+ #unadmittedNotices = [];
59
57
  #sealed = false;
60
58
  constructor(store, adapter, config, session, input, externalSignal, resume,
61
59
  // TV-1B: durable PROVENANCE for the input (e.g. source:"system"
@@ -89,23 +87,72 @@ export class Run {
89
87
  steer(content) {
90
88
  if (this.#sealed)
91
89
  throw new RunClosedError(this.runId);
92
- this.#pending.push(content);
90
+ this.#ingress.push({ kind: "human", content });
91
+ // ADR-0057 §5: a long foreground command no longer holds the steer
92
+ this.#session.detachForSteer();
93
+ }
94
+ /** ADR-0058 (3c) — hand this run a task delivery. It is admitted at the
95
+ * next Safe Admission boundary, in arrival order with the person's
96
+ * steers, as a `source: "system"` user_input whose `via` is the receipt;
97
+ * it never passes onUserMessage. False once the run has decided to end
98
+ * — the caller keeps the notice for the next run or a wake. */
99
+ notify(notice) {
100
+ if (this.#sealed)
101
+ return false;
102
+ this.#ingress.push({ kind: "runtime", notice });
103
+ return true;
93
104
  }
94
- /** ADR-0057 — take back the input that has not been admitted yet. */
105
+ /** ADR-0057 — take back the person's input that has not been admitted
106
+ * yet. A task notice is not the person's, and stays. */
95
107
  retract() {
96
- return this.#pending.splice(0);
108
+ const taken = [];
109
+ for (let i = this.#ingress.length - 1; i >= 0; i--) {
110
+ const entry = this.#ingress[i];
111
+ if (entry.kind === "human") {
112
+ taken.unshift(entry.content);
113
+ this.#ingress.splice(i, 1);
114
+ }
115
+ }
116
+ return taken;
97
117
  }
98
- /** ADR-0057 — input that arrived but was not admitted when the run ended. */
118
+ /** ADR-0057 — the person's input that arrived but was not admitted when
119
+ * the run ended. */
99
120
  unadmitted() {
100
121
  return [...this.#unadmitted];
101
122
  }
102
- /** The kernel's `admit`: what is pending becomes ONE human input — a
103
- * single line as itself, several as text blocks in arrival order. */
123
+ /** ADR-0058 (3c) — task notices still pending when the run ended: the
124
+ * delivery takes them back, never lost. */
125
+ unadmittedNotices() {
126
+ return [...this.#unadmittedNotices];
127
+ }
128
+ /** The kernel's `admit`: the ingress in arrival order, neighbours of one
129
+ * kind merged — a person's lines as ONE human input (a single line as
130
+ * itself, several as text blocks), task notices as ONE runtime input. */
104
131
  async #admit(mode) {
105
- if (mode !== "seal" && this.#pending.length > 0) {
106
- const lines = this.#pending.splice(0);
107
- const content = lines.length === 1 ? lines[0] : lines.flatMap((l) => (typeof l === "string" ? [{ type: "text", text: l }] : l));
108
- return [{ kind: "human", content, source: "user" }];
132
+ if (mode !== "seal" && this.#ingress.length > 0) {
133
+ // group neighbours of one kind, in arrival order
134
+ const groups = [];
135
+ for (const entry of this.#ingress.splice(0)) {
136
+ const last = groups.at(-1);
137
+ if (entry.kind === "human") {
138
+ if (last?.kind === "human")
139
+ last.contents.push(entry.content);
140
+ else
141
+ groups.push({ kind: "human", contents: [entry.content] });
142
+ }
143
+ else if (last?.kind === "runtime")
144
+ last.notices.push(entry.notice);
145
+ else
146
+ groups.push({ kind: "runtime", notices: [entry.notice] });
147
+ }
148
+ return groups.map((g) => {
149
+ if (g.kind === "human") {
150
+ const content = g.contents.length === 1 ? g.contents[0] : g.contents.flatMap((l) => (typeof l === "string" ? [{ type: "text", text: l }] : l));
151
+ return { kind: "human", content, source: "user" };
152
+ }
153
+ const content = [...g.notices.flatMap((n) => n.lines), NOTICE_FOOTER].join("\n");
154
+ return { kind: "runtime", content, source: "system", via: { kind: "tasks", items: g.notices.flatMap((n) => n.items) } };
155
+ });
109
156
  }
110
157
  if (mode !== "take")
111
158
  this.#seal();
@@ -113,7 +160,12 @@ export class Run {
113
160
  }
114
161
  #seal() {
115
162
  this.#sealed = true;
116
- this.#unadmitted.push(...this.#pending.splice(0));
163
+ for (const entry of this.#ingress.splice(0)) {
164
+ if (entry.kind === "human")
165
+ this.#unadmitted.push(entry.content);
166
+ else
167
+ this.#unadmittedNotices.push(entry.notice);
168
+ }
117
169
  }
118
170
  async *[Symbol.asyncIterator]() {
119
171
  if (this.#started)
package/dist/session.d.ts CHANGED
@@ -33,6 +33,8 @@ import { type ContinuationScope } from "@vincemakes/kiso-core";
33
33
  import { type ReasoningSetting } from "./provider/metadata.js";
34
34
  import { type SessionStore, type StoreRecord } from "./store.js";
35
35
  import { Run } from "./run.js";
36
+ import { type TaskDeliveryOptions } from "./tasks/delivery.js";
37
+ import type { TaskManager } from "./tasks/manager.js";
36
38
  /** TUI2-R3v2 ③ — one off-trajectory model request (session.sideQuery).
37
39
  * Deliberately tiny: a purpose to mark it in the trace, its own short
38
40
  * system prompt, one user message, and an abort. No tools — a side
@@ -89,6 +91,9 @@ export interface CompactInfo {
89
91
  /** The covered content's estimated tokens (the chars/4 proxy). */
90
92
  readonly tokens: number;
91
93
  }
94
+ /** ADR-0057 §5 (3e D1): a steer detaches a foreground command only once it
95
+ * is this old — a command about to end on its own is left to end. */
96
+ export declare const AUTO_DETACH_MIN_AGE_MS = 2000;
92
97
  export declare class AgentSession {
93
98
  #private;
94
99
  readonly id: string;
@@ -122,6 +127,19 @@ export declare class AgentSession {
122
127
  persist(runId: string, event: Event): Promise<void>;
123
128
  beginRun(run: Run): void;
124
129
  endRun(run: Run): void;
130
+ /** Deliver this session's task transitions to the model: into a live
131
+ * run at its next safe point, or — idle — held for the next run, or one
132
+ * continuation run through `onWake` (off with `wake: false`). */
133
+ useTasks(manager: TaskManager, options?: Pick<TaskDeliveryOptions, "wake" | "onWake" | "windowMs"> & {
134
+ readonly autoDetachMinAgeMs?: number;
135
+ }): () => void;
136
+ /** ADR-0057 §5 (3e D1) — a person's steer arrived: the foreground
137
+ * commands running NOW stop holding it back. Each is detached once it is
138
+ * `autoDetachMinAgeMs` old — at once if it already is — so a command
139
+ * about to end anyway is not turned into a task. Commands started after
140
+ * the steer are never touched; a command that ends first unregistered
141
+ * itself, and its detach is a no-op. Called by the run. */
142
+ detachForSteer(): void;
125
143
  /** The conversation so far, as the model sees it. */
126
144
  projected(): readonly Message[];
127
145
  /** 0.40.0: the context as the last bill measured it, plus what the log
@@ -309,6 +327,7 @@ export declare class AgentSession {
309
327
  signal?: AbortSignalLike;
310
328
  source?: import("@vincemakes/kiso-core").MessageSource;
311
329
  via?: import("@vincemakes/kiso-core").UserInputVia;
330
+ maxTurns?: number;
312
331
  }): Run;
313
332
  /**
314
333
  * Continue the interrupted run (Area 2): apply durable decisions,
package/dist/session.js CHANGED
@@ -47,6 +47,7 @@ import { checkpointBoundarySeq } from "./checkpoint.js";
47
47
  import { ABORTED_BEFORE_EXECUTION, unansweredAbortedCalls } from "./aborted-calls.js";
48
48
  import { breakEvenFactor, guardedPruneSeq, KEEP_COMPACTABLE_RESULTS, microcompactBoundarySeq, outputReserve, phaseEnd, runsACheck, tierReason, tiersFor } from "./compaction-policy.js";
49
49
  import { Run } from "./run.js";
50
+ import { TaskDelivery } from "./tasks/delivery.js";
50
51
  // TUI2-R3v2 ③ — the side query rides the SAME tracer the runs ride; that
51
52
  // sameness is the whole point (one ledger, one shape, no second path).
52
53
  import { RequestTracer, traceGuard } from "./trace/guard.js";
@@ -90,6 +91,9 @@ function summaryReasoning(model, baseUrl) {
90
91
  const r = resolveReasoning(model, { thinking: "disabled", effort: "default" }, baseUrl);
91
92
  return r.ok && r.wire.thinking !== undefined ? { reasoning: r.wire } : {};
92
93
  }
94
+ /** ADR-0057 §5 (3e D1): a steer detaches a foreground command only once it
95
+ * is this old — a command about to end on its own is left to end. */
96
+ export const AUTO_DETACH_MIN_AGE_MS = 2_000;
93
97
  export class AgentSession {
94
98
  id;
95
99
  log;
@@ -266,9 +270,61 @@ export class AgentSession {
266
270
  throw new Error("this session already has an active run — one run at a time");
267
271
  }
268
272
  this.#activeRuns.add(run);
273
+ this.#delivery?.takeForRun(run); // ADR-0058: pending task notices ride its first admission
269
274
  }
270
275
  endRun(run) {
271
276
  this.#activeRuns.delete(run);
277
+ this.#delivery?.onRunSettled(run); // the slot is free: a wake may follow
278
+ }
279
+ // ── ADR-0058 (3c): the session's tasks reach the model ────────────────
280
+ #delivery;
281
+ #tasks;
282
+ #autoDetachMinAgeMs = AUTO_DETACH_MIN_AGE_MS;
283
+ /** Deliver this session's task transitions to the model: into a live
284
+ * run at its next safe point, or — idle — held for the next run, or one
285
+ * continuation run through `onWake` (off with `wake: false`). */
286
+ useTasks(manager,
287
+ // autoDetachMinAgeMs (3e D1): how old a foreground command must be
288
+ // before a steer detaches it — default AUTO_DETACH_MIN_AGE_MS
289
+ options = {}) {
290
+ this.#delivery?.close();
291
+ const { autoDetachMinAgeMs, ...deliveryOptions } = options;
292
+ const delivery = new TaskDelivery({ ...deliveryOptions, manager, events: () => this.log.all, liveRun: () => [...this.#activeRuns][0] });
293
+ this.#delivery = delivery;
294
+ this.#tasks = manager;
295
+ this.#autoDetachMinAgeMs = autoDetachMinAgeMs ?? AUTO_DETACH_MIN_AGE_MS;
296
+ return () => {
297
+ delivery.close();
298
+ if (this.#delivery === delivery)
299
+ this.#delivery = undefined;
300
+ if (this.#tasks === manager)
301
+ this.#tasks = undefined;
302
+ };
303
+ }
304
+ /** ADR-0057 §5 (3e D1) — a person's steer arrived: the foreground
305
+ * commands running NOW stop holding it back. Each is detached once it is
306
+ * `autoDetachMinAgeMs` old — at once if it already is — so a command
307
+ * about to end anyway is not turned into a task. Commands started after
308
+ * the steer are never touched; a command that ends first unregistered
309
+ * itself, and its detach is a no-op. Called by the run. */
310
+ detachForSteer() {
311
+ const manager = this.#tasks;
312
+ if (manager === undefined)
313
+ return;
314
+ const now = Date.now();
315
+ for (const { executionId, startedAt } of manager.detachable()) {
316
+ const wait = Math.max(0, this.#autoDetachMinAgeMs - (now - startedAt));
317
+ if (wait === 0)
318
+ manager.detach(executionId, "steer");
319
+ else
320
+ setTimeout(() => manager.detach(executionId, "steer"), wait).unref?.();
321
+ }
322
+ }
323
+ /** A summary carries the tasks as the model was last told of them —
324
+ * never the live journal (a compaction is not a delivery). */
325
+ #withTasks(summary) {
326
+ const snapshot = this.#delivery?.snapshot(this.log.all) ?? "";
327
+ return snapshot === "" ? summary : `${summary}\n\n${snapshot}`;
272
328
  }
273
329
  /** The conversation so far, as the model sees it. */
274
330
  projected() {
@@ -414,10 +470,11 @@ export class AgentSession {
414
470
  // against an estimate). A checkpoint that fails it is discarded:
415
471
  // nothing is appended, the run goes on as before, and the failure
416
472
  // counts toward the breaker.
417
- const fire = { type: "summarized", coversToSeq: boundary, summary: result.text };
473
+ const text = this.#withTasks(result.text);
474
+ const fire = { type: "summarized", coversToSeq: boundary, summary: text };
418
475
  const pre = estimateTokens(projectMessages(events));
419
476
  const post = estimateTokens(projectMessages([...events, { ...fire, seq: (events.at(-1)?.seq ?? -1) + 1 }]));
420
- const written = estimateTokens([{ role: "user", content: `${SUMMARY_FRAMING}\n\n${result.text}` }]);
477
+ const written = estimateTokens([{ role: "user", content: `${SUMMARY_FRAMING}\n\n${text}` }]);
421
478
  const shrinks = pre - post >= written;
422
479
  // Every in-run fire is recorded — its reason and path are what the
423
480
  // measurement counts — and its usage when the provider reported one;
@@ -734,11 +791,15 @@ export class AgentSession {
734
791
  * OpenAI-compatible family as `image_url` with a data URI. Only this
735
792
  * signature narrowed it to text, which is why no caller could ever
736
793
  * send one. */
737
- run(input, options) {
794
+ run(input,
795
+ // ADR-0058 3d: `maxTurns` bounds THIS run only (a background child's
796
+ // wrap-up gets one request), over the session's own limit
797
+ options) {
738
798
  this.ensureHealthy();
739
799
  if (this.#profilePending)
740
800
  this.#recordProfile(); // XP-1: legacy revision 1, before the first request
741
- return new Run(this.#store, this.#learning(this.#adapter), this.#effectiveConfig(), this, input, options?.signal, false, options?.source, options?.via);
801
+ const config = options?.maxTurns !== undefined ? { ...this.#effectiveConfig(), maxTurns: options.maxTurns } : this.#effectiveConfig();
802
+ return new Run(this.#store, this.#learning(this.#adapter), config, this, input, options?.signal, false, options?.source, options?.via);
742
803
  }
743
804
  /**
744
805
  * Continue the interrupted run (Area 2): apply durable decisions,
@@ -862,7 +923,7 @@ export class AgentSession {
862
923
  // adapter returned must NOT persist — "nothing happened".
863
924
  if (options.signal !== undefined && options.signal.aborted)
864
925
  throw cancelled();
865
- const full = this.log.append({ type: "summarized", coversToSeq: boundary, summary });
926
+ const full = this.log.append({ type: "summarized", coversToSeq: boundary, summary: this.#withTasks(summary) });
866
927
  // The record rides the LAST recorded run's id — a summarized fact
867
928
  // must never open a run of its own: the open-run gate keys on
868
929
  // terminal-less runIds, and a "compact" runId would block the next
package/dist/store.js CHANGED
@@ -42,7 +42,7 @@
42
42
  * - load is strict (A group round 1): a partial final line is the only
43
43
  * tolerated damage; everything else throws StoreCorruptionError.
44
44
  */
45
- import { appendFileSync, closeSync, existsSync, fsyncSync, fstatSync, statSync, ftruncateSync, mkdirSync, openSync, readFileSync, readdirSync, readSync, } from "node:fs";
45
+ import { appendFileSync, closeSync, constants, existsSync, fsyncSync, fstatSync, statSync, ftruncateSync, mkdirSync, openSync, readFileSync, readdirSync, readSync, writeSync, } from "node:fs";
46
46
  import { dirname, join } from "node:path";
47
47
  import { isKisoEvent } from "@vincemakes/kiso-core";
48
48
  import { LockedError, nativeLockAdapter, } from "./lock-adapter.js";
@@ -135,7 +135,8 @@ function contentText(content) {
135
135
  export function sessionTitle(records) {
136
136
  const asked = records
137
137
  .map((r) => r.event)
138
- .filter((e) => e.type === "user_input")
138
+ // ADR-0058: a task notice is the runtime's, never the person's words
139
+ .filter((e) => e.type === "user_input" && e.via?.kind !== "tasks")
139
140
  // 0.40.0: a skill turn is named by what the person TYPED — its
140
141
  // content is a SKILL.md body the person never wrote.
141
142
  .map((e) => (typeof e.via?.line === "string" ? e.via.line : contentText(e.content)).trim())
@@ -312,7 +313,13 @@ export class SessionStore {
312
313
  if (event.seq !== expected) {
313
314
  throw new StaleWriterError(expected, event.seq);
314
315
  }
315
- appendFileSync(fd, `${JSON.stringify({ runId, ts: Date.now(), event })}\n`);
316
+ const line = `${JSON.stringify({ runId, ts: Date.now(), event })}\n`;
317
+ // Windows: the log is open read-write (see fd()), so the append is a
318
+ // write at the end — the lock makes this the only writer
319
+ if (process.platform === "win32")
320
+ writeSync(fd, line, fstatSync(fd).size);
321
+ else
322
+ appendFileSync(fd, line);
316
323
  // A streamed fragment is not a durability boundary: it belongs to a
317
324
  // turn that is uncommitted until its stop is durable, and the next
318
325
  // synced event (the call's end, the text's end, the stop) flushes
@@ -340,7 +347,10 @@ export class SessionStore {
340
347
  const path = this.pathFor(sessionId);
341
348
  // R6: the mode applies at CREATION only — an existing log keeps
342
349
  // whatever it has, which is the not-migrated rule above.
343
- const fd = openSync(path, "a+", 0o600);
350
+ // Windows: a handle opened for append can be neither flushed nor
351
+ // truncated (FlushFileBuffers and SetEndOfFile need the write access
352
+ // append mode drops), so the log opens read-write there
353
+ const fd = openSync(path, process.platform === "win32" ? constants.O_RDWR | constants.O_CREAT : "a+", 0o600);
344
354
  repairTornTail(fd);
345
355
  fsyncDir(dirname(path));
346
356
  this.#fds.set(sessionId, fd);
@@ -0,0 +1,82 @@
1
+ /**
2
+ * ADR-0058 §7–§8, step 3c — a task's transitions reach the model.
3
+ *
4
+ * One delivery per session. It hears the TaskManager's transitions,
5
+ * decides each by the §8 defaults (silent | notify | wake), merges what
6
+ * arrives within a window into ONE notice, and hands it on:
7
+ *
8
+ * a live run → `run.notify` — admitted at the next safe point, in
9
+ * arrival order with the person's steers (ADR-0057);
10
+ * an idle session → held for the next run a person starts, or — when a
11
+ * transition asks to wake — ONE continuation run whose
12
+ * first input is the notice (the host starts it, after
13
+ * the previous run has fully settled).
14
+ *
15
+ * Exactly once, from the log: a transition counts as delivered when a
16
+ * `user_input` whose `via.items` names it is in the session's log (ADR-0051
17
+ * Amendment 8). The set is a cache rebuilt from the log, never a second
18
+ * truth. Lineage from the log too: a task started inside a wake run (a run
19
+ * whose first input is a task notice) never wakes — depth 1.
20
+ *
21
+ * The summary snapshot is what the MODEL was told, never the live journal:
22
+ * a compaction must not reveal a transition that has not been delivered.
23
+ *
24
+ * A transition a tool result reports (ADR-0058 Amendment 7) is CLAIMED in
25
+ * the task journal by that execution and never noticed — the manager does
26
+ * not announce it, and a restart does not deliver it. The claim decides
27
+ * only that; what the model knows stays the log's: the summary learns a
28
+ * claimed transition from the claiming execution's durable, successful
29
+ * tool_result, never from the journal alone.
30
+ *
31
+ * Background children (ADR-0058 3d) arrive as a GROUP: the agent tasks
32
+ * started by the calls of one model turn. The group closes once that turn
33
+ * has ended and every call in it has its result — before that, a fast
34
+ * child's end says nothing about the group. A closed group whose members
35
+ * have all ended is delivered ONCE: every member's line, then excerpts of
36
+ * the answers (result.md, which the child wrote) within 4 KiB each and
37
+ * 16 KiB together; members already delivered are named as reported, and
38
+ * only the rest are receipted. A failure goes into a live run at once and
39
+ * never wakes on its own; idle, it waits for its group.
40
+ */
41
+ import { type Event, type UserInputVia } from "@vincemakes/kiso-core";
42
+ import { type Run } from "../run.js";
43
+ import { type TaskInfo, type TaskManager } from "./manager.js";
44
+ export interface TaskDeliveryOptions {
45
+ readonly manager: TaskManager;
46
+ /** The session's durable events: receipts and lineage are read here. */
47
+ readonly events: () => readonly Event[];
48
+ /** The session's live run, when one is running. */
49
+ readonly liveRun: () => Run | undefined;
50
+ /** False turns every wake into a notify (the switch, ADR-0058 §8.4). */
51
+ readonly wake?: boolean;
52
+ /** Start ONE continuation run whose first input is this notice. */
53
+ readonly onWake?: (input: {
54
+ readonly content: string;
55
+ readonly via: UserInputVia;
56
+ }) => void;
57
+ /** Transitions within this window merge into one notice. Default 1 s. */
58
+ readonly windowMs?: number;
59
+ /** How much of a failed task's output rides its notice. Default 2 KB. */
60
+ readonly tailBytes?: number;
61
+ /** Read a task's output tail (injectable for tests). */
62
+ readonly readTail?: (task: TaskInfo, bytes: number) => string;
63
+ }
64
+ export declare class TaskDelivery {
65
+ #private;
66
+ constructor(options: TaskDeliveryOptions);
67
+ close(): void;
68
+ /** A run a person started: pending notices ride its first admission. */
69
+ takeForRun(run: Run): void;
70
+ /** The run has fully settled (its terminal durable, the slot free):
71
+ * notices it never admitted come back, and a wake may start. */
72
+ onRunSettled(run: Run): void;
73
+ /** The conversation's view of its tasks for a summary: a task the model
74
+ * was told started, at the last transition it was TOLD about. Empty
75
+ * when the model knows of no task. Told started = a durable, successful
76
+ * tool_result for the execution that started it — a started execution
77
+ * with no result (the crash window), or one resolved as not applied,
78
+ * never told the model a task exists. Told a transition = a notice
79
+ * naming it, or (Amendment 7) a durable, successful tool_result of the
80
+ * execution that claimed it — in log order, the last one wins. */
81
+ snapshot(events: readonly Event[]): string;
82
+ }