@vincemakes/kiso-code 0.46.1 → 0.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/auth/oauth/chatgpt.js +25 -8
  2. package/dist/auth/oauth/page.d.ts +25 -0
  3. package/dist/auth/oauth/page.js +44 -0
  4. package/dist/chat.d.ts +12 -1
  5. package/dist/chat.js +133 -46
  6. package/dist/coding-prompt.d.ts +7 -0
  7. package/dist/coding-prompt.js +12 -4
  8. package/dist/config.d.ts +7 -3
  9. package/dist/config.js +39 -4
  10. package/dist/create-coding-agent.js +16 -6
  11. package/dist/dispatch.d.ts +2 -0
  12. package/dist/dispatch.js +337 -83
  13. package/dist/floor.d.ts +21 -0
  14. package/dist/floor.js +35 -35
  15. package/dist/git-branch.d.ts +17 -0
  16. package/dist/git-branch.js +69 -0
  17. package/dist/in-run-compaction.d.ts +54 -0
  18. package/dist/in-run-compaction.js +44 -0
  19. package/dist/index.js +106 -45
  20. package/dist/opening.d.ts +48 -0
  21. package/dist/opening.js +43 -0
  22. package/dist/protected-writes.d.ts +8 -0
  23. package/dist/protected-writes.js +12 -0
  24. package/dist/remote-boundary.d.ts +55 -0
  25. package/dist/remote-boundary.js +217 -0
  26. package/dist/replay.d.ts +8 -1
  27. package/dist/replay.js +22 -10
  28. package/dist/resume.js +21 -16
  29. package/dist/session-cards.d.ts +2 -0
  30. package/dist/session-cards.js +3 -3
  31. package/dist/settings.d.ts +19 -0
  32. package/dist/settings.js +34 -5
  33. package/dist/skill-invoke.d.ts +11 -0
  34. package/dist/skill-invoke.js +59 -0
  35. package/dist/state.d.ts +14 -3
  36. package/dist/state.js +29 -3
  37. package/dist/task-notice.d.ts +48 -7
  38. package/dist/task-notice.js +123 -13
  39. package/dist/trust-ui.d.ts +7 -0
  40. package/dist/trust-ui.js +54 -9
  41. package/dist/window-title.d.ts +43 -23
  42. package/dist/window-title.js +78 -50
  43. package/package.json +14 -14
@@ -18,6 +18,7 @@
18
18
  */
19
19
  import { createServer } from "node:http";
20
20
  import { RefreshRejectedError } from "./index.js";
21
+ import { callbackPage } from "./page.js";
21
22
  import { generatePkce, randomState } from "./pkce.js";
22
23
  export const CHATGPT = {
23
24
  providerId: "chatgpt",
@@ -122,30 +123,46 @@ export function credentialFromTokens(tokens) {
122
123
  throw new Error("ChatGPT sign-in: the access token carries no chatgpt_account_id claim — signed in to something the backend will not serve");
123
124
  return { type: "oauth", access: tokens.access, refresh: tokens.refresh, expires: tokens.expires, accountId, savedAt: Date.now() };
124
125
  }
125
- const PAGE = (title, body) => `<!doctype html><meta charset="utf-8"><title>${title}</title><body style="font:16px system-ui;padding:2rem"><h1>${title}</h1><p>${body}</p></body>`;
126
126
  /** The local callback: one code for one state, or null when the port cannot
127
127
  * be bound (the paste path takes over). */
128
128
  export function startCallbackServer(state, host, port) {
129
129
  return new Promise((resolve) => {
130
130
  let settle;
131
- const waited = new Promise((r) => {
131
+ let fail;
132
+ const waited = new Promise((r, j) => {
132
133
  settle = r;
134
+ fail = j;
133
135
  });
136
+ // a failure that lands after the paste path won the race has no one
137
+ // awaiting it — it must not become an unhandled rejection
138
+ waited.catch(() => { });
134
139
  const server = createServer((req, res) => {
135
140
  const url = new URL(req.url ?? "", `http://${host}`);
136
- const reply = (status, title, body) => {
141
+ // Graphite R3c: the page looks like kiso.work (page.ts)
142
+ const reply = (status, page) => {
137
143
  res.statusCode = status;
138
144
  res.setHeader("Content-Type", "text/html; charset=utf-8");
139
- res.end(PAGE(title, body));
145
+ res.end(callbackPage(page));
140
146
  };
141
147
  if (url.pathname !== "/auth/callback")
142
- return reply(404, "Not found", "This is kiso's sign-in callback.");
148
+ return reply(404, { kind: "note", state: "not found", title: "Nothing here.", body: "This is kiso's sign-in callback. Return to your terminal." });
143
149
  if (url.searchParams.get("state") !== state)
144
- return reply(400, "State mismatch", "The sign-in did not start from this kiso. Start again.");
150
+ return reply(400, { kind: "fail", state: "state mismatch", title: "Start again.", body: "This sign-in did not start from the kiso that is waiting. Run kiso login chatgpt again in your terminal." });
151
+ // R3c: a sign-in the person declined (or the server refused) comes
152
+ // back as `error` and `error_description` — said as itself, here
153
+ // and in the terminal, where it used to read "Missing code"
154
+ const error = url.searchParams.get("error");
155
+ if (error !== null) {
156
+ const why = url.searchParams.get("error_description");
157
+ const reason = why === null || why === "" ? error : `${error}: ${why}`;
158
+ reply(400, { kind: "fail", state: "sign-in failed", title: "Sign-in failed.", body: "Return to your terminal and run kiso login chatgpt again.", detail: reason });
159
+ fail?.(new Error(`ChatGPT sign-in failed — ${reason}`));
160
+ return;
161
+ }
145
162
  const code = url.searchParams.get("code");
146
163
  if (!code)
147
- return reply(400, "Missing code", "The authorization server sent no code.");
148
- reply(200, "Signed in", "You can close this window and return to kiso.");
164
+ return reply(400, { kind: "fail", state: "sign-in failed", title: "No code came back.", body: "The authorization server sent no code. Return to your terminal and run kiso login chatgpt again." });
165
+ reply(200, { kind: "ok", state: "signed in", title: "You're signed in.", body: "kiso has your ChatGPT sign-in. Close this tab and return to your terminal." });
149
166
  settle?.(code);
150
167
  });
151
168
  server.on("error", () => resolve(null));
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Graphite R3c — the page a browser shows when a sign-in comes back to
3
+ * kiso's local callback. It is the one kiso surface outside the terminal,
4
+ * so it looks like kiso.work: the quatrefoil mark and the `kiso` wordmark,
5
+ * the site's warm ground and ink (a dark twin for `prefers-color-scheme:
6
+ * dark`), a small mono status line with its dot, one large headline, one
7
+ * sentence, and the reason in a mono box when there is one. Static — no
8
+ * script, no request, no web font (the system's own faces stand in for
9
+ * the site's) — and every word from outside (the server's
10
+ * `error_description`) is escaped before it reaches the page.
11
+ */
12
+ export declare const escapeHtml: (s: string) => string;
13
+ export interface CallbackPage {
14
+ /** the dot's colour, and the status line's word */
15
+ readonly kind: "ok" | "fail" | "note";
16
+ /** the headline — its trailing period is drawn muted, like the site's */
17
+ readonly title: string;
18
+ /** one sentence under it */
19
+ readonly body: string;
20
+ /** a reason from outside (the server's), shown verbatim in a mono box */
21
+ readonly detail?: string;
22
+ /** the status line's words (`signed in`, `sign-in failed`) */
23
+ readonly state: string;
24
+ }
25
+ export declare function callbackPage(page: CallbackPage): string;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Graphite R3c — the page a browser shows when a sign-in comes back to
3
+ * kiso's local callback. It is the one kiso surface outside the terminal,
4
+ * so it looks like kiso.work: the quatrefoil mark and the `kiso` wordmark,
5
+ * the site's warm ground and ink (a dark twin for `prefers-color-scheme:
6
+ * dark`), a small mono status line with its dot, one large headline, one
7
+ * sentence, and the reason in a mono box when there is one. Static — no
8
+ * script, no request, no web font (the system's own faces stand in for
9
+ * the site's) — and every word from outside (the server's
10
+ * `error_description`) is escaped before it reaches the page.
11
+ */
12
+ const ESC = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
13
+ export const escapeHtml = (s) => s.replace(/[&<>"']/g, (c) => ESC[c]);
14
+ /** kiso's quatrefoil — the mark in `assets/logo.svg`, shared with kiso.work. */
15
+ const MARK = '<svg class="mark" viewBox="0 0 100 100" aria-hidden="true"><path fill="currentColor" d="m89 54c-0.5-1.3-0.8-2.7-0.8-3.9 0.2-5.5 7-11.1 9.5-19.4 2.2-7.3 2.2-17.3-5.2-23.8-2.7-2.4-7-4.7-12.7-4.7-2.6 0-5.3 0.4-7.8 1.2-4.7 1.5-7.8 3.5-14.5 7.8-2.6 1.5-4.6 2.6-7.5 2.6s-5-1.1-7.5-2.6c-6.7-4.3-11.1-7.2-15.8-8.3-1.6-0.4-3.5-0.7-6.1-0.7-5.7 0-10 2-12.9 4.7-7.3 6.7-7.4 16-5.2 22.9 2.6 7.9 8.4 13.1 8.9 18.8 0.2 1.6 0 3.5-0.9 5.4-2.8 5.7-8.4 10.9-8.9 21.5-0.2 4.2 0.3 8.9 2.8 13.1 2.1 3.3 6.5 8.3 16 8.5 12.5 0.2 18.8-6.9 25.8-9.6 1.3-0.5 2.5-0.7 3.8-0.7 6.7 0 11.4 6.9 21.9 9.8 2.2 0.7 4.7 1.1 7.5 1.2 4.2 0 7.5-0.8 10.2-2.3 4-2.1 6.5-5.8 7.7-8.8 1.1-2.9 1.6-5.9 1.6-8.8-0.2-10.6-7.3-17.4-9.9-23.9zm-71.2-3.6c6.4-0.4 11.6-1 17.4-4.2 3.3-1.8 6.4-4.5 8.6-7.3 3.1-4.2 5.6-9.5 6.1-17.1l0.1-2.2 0.2 2.2c1.2 13.1 9.3 20.8 16.2 24.3 4.5 2.4 8.7 3.6 15.8 4.1v0.2c-5.3 0.2-9.3 1-13.2 2.6-4.8 1.9-9.4 5.1-12.6 9.2-3.5 4.7-5.8 10-6.3 17.5l-0.1 1.4-0.2-2c-0.4-5.7-2.3-11-5.4-15.3-2.3-3.3-5.3-6.1-8.9-8.2-3.4-2.1-8-4.2-15.9-5.1l-1.8-0.1z"/></svg>';
16
+ const STYLE = [
17
+ ":root{--bg:#f7f7f4;--ink:#20211f;--muted:#666761;--line:#d9dad4;--ok:#2f7a3a;--fail:#b3261e;--sans:ui-sans-serif,-apple-system,'Helvetica Neue',Arial,sans-serif;--mono:ui-monospace,'SF Mono',Menlo,monospace;--pad:clamp(20px,5vw,72px)}",
18
+ "@media (prefers-color-scheme:dark){:root{--bg:#151513;--ink:#ecece6;--muted:#9a9b93;--line:#33342f;--ok:#8fd19e;--fail:#f2877a}}",
19
+ "*{box-sizing:border-box}html,body{margin:0;min-height:100%}",
20
+ "body{min-height:100vh;display:flex;flex-direction:column;background:var(--bg);color:var(--ink);font:16px/1.6 var(--sans);-webkit-font-smoothing:antialiased}",
21
+ "header,footer{display:flex;justify-content:space-between;align-items:center;gap:16px;padding:24px var(--pad)}",
22
+ ".logo{display:flex;align-items:center;gap:10px;font-size:26px;font-weight:600;letter-spacing:-1.3px}.mark{width:26px;height:26px}",
23
+ ".mono{font-family:var(--mono);font-size:11px;letter-spacing:.07em;text-transform:uppercase;color:var(--muted)}",
24
+ "main{flex:1;display:flex;flex-direction:column;justify-content:center;padding:32px var(--pad) 56px;max-width:880px}",
25
+ ".state{display:flex;align-items:center;gap:10px}.dot{width:7px;height:7px;border-radius:50%;background:var(--tone)}",
26
+ "h1{margin:22px 0 0;font-size:clamp(40px,7vw,76px);line-height:1.03;font-weight:500;letter-spacing:-.06em}.period{color:var(--muted)}",
27
+ "p.lead{margin:22px 0 0;color:var(--muted);font-size:17px;max-width:34rem}",
28
+ "pre{margin:26px 0 0;padding:14px 16px;border:1px solid var(--line);font:13px/1.55 var(--mono);white-space:pre-wrap;overflow-wrap:anywhere;max-width:40rem}",
29
+ "code{font:14px var(--mono);color:var(--ink)}",
30
+ "footer{border-top:1px solid var(--line)}",
31
+ ].join("");
32
+ export function callbackPage(page) {
33
+ const tone = page.kind === "ok" ? "var(--ok)" : page.kind === "fail" ? "var(--fail)" : "var(--muted)";
34
+ const title = escapeHtml(page.title.replace(/\.$/, ""));
35
+ return [
36
+ `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">`,
37
+ `<title>${escapeHtml(page.title.replace(/\.$/, ""))} · kiso</title><style>${STYLE}</style>`,
38
+ `<header><div class="logo">${MARK}kiso</div><span class="mono">ChatGPT sign-in</span></header>`,
39
+ `<main style="--tone:${tone}"><div class="state mono"><span class="dot"></span>${escapeHtml(page.state)}</div>`,
40
+ `<h1>${title}<span class="period">.</span></h1><p class="lead">${escapeHtml(page.body)}</p>`,
41
+ page.detail === undefined ? "" : `<pre>${escapeHtml(page.detail)}</pre>`,
42
+ `</main><footer><span class="mono">A small core. A long memory.</span><span class="mono">kiso.work</span></footer></html>`,
43
+ ].join("");
44
+ }
package/dist/chat.d.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * estimates. All bodies moved verbatim from index.ts.
6
6
  */
7
7
  import type { SessionRoute } from "./projects.js";
8
- import { type RunUsage } from "@vincemakes/kiso-tui";
8
+ import { type BarInput, type RunUsage, type TaskCountsOnRow } from "@vincemakes/kiso-tui";
9
9
  import { type SaferFailure, type SaferOption } from "@vincemakes/kiso-tui";
10
10
  import type { AgentSession, Run } from "@vincemakes/kiso-runtime";
11
11
  import { type LineInput } from "./state.js";
@@ -150,6 +150,17 @@ export declare function compactionDiscardedNotice(d: {
150
150
  readonly summary: number;
151
151
  }): string;
152
152
  export declare function displayCtxRatio(session: AgentSession): number;
153
+ /**
154
+ * Graphite §8.9 — the status bar's facts, one builder for every caller
155
+ * (the boot row, the idle row, the running turn's row), so they cannot
156
+ * drift apart. The context share is USED, with the runtime's own
157
+ * compaction tiers as shares of the same window (`tiersFor`, never a
158
+ * fixed fraction); an unknown window is `ctx ?`.
159
+ */
160
+ export declare function barFor(session: AgentSession, measured?: {
161
+ readonly tokPerSec: number | null;
162
+ readonly tasks?: TaskCountsOnRow | undefined;
163
+ }): BarInput;
153
164
  /** 0.40.0 item 9: how long a prompt cache is assumed to live. PROVISIONAL:
154
165
  * DeepSeek does not publish its TTL (the owner's cache was gone after 27
155
166
  * minutes); Anthropic's default is 5 minutes. */
package/dist/chat.js CHANGED
@@ -5,16 +5,19 @@
5
5
  * estimates. All bodies moved verbatim from index.ts.
6
6
  */
7
7
  import { readFileSync, statSync } from "node:fs";
8
- import { escapeTerminal, cacheHitPct, decodeRate, idleStatus, palette, renderEvent, renderRecap, runningStatus, toolTarget, STATUS_GLYPHS, kUnit, namedPickView, } from "@vincemakes/kiso-tui";
9
- import { askView, coldResumeLine, coldResumeView, deletionRiskHint, editFileDiff, writeFileDiff } from "@vincemakes/kiso-tui";
8
+ import { escapeTerminal, cacheHitPct, decodeRate, palette, renderEvent, renderRecap, sealTiers, toolTarget, STATUS_GLYPHS, kUnit, workingRow, compactingStatus, liveRow, namedPickView, } from "@vincemakes/kiso-tui";
9
+ import { askView, coldResumeLine, coldResumeView, deletionRiskHint, editFileHunksDiff, hunksOf, writeFileDiff } from "@vincemakes/kiso-tui";
10
10
  import { canonicalTargetPath, isProtectedPath, protectedIdentity, shellProgressPath } from "@vincemakes/kiso-tools-node";
11
- import { liveTasks, mergedConfig, queuedSwitchLines, seenUnknownTasks, setExitTasks, tasksFor } from "./state.js";
12
- import { taskCounts, taskNoticeRow } from "./task-notice.js";
11
+ import { deliveryLines, liveTasks, lostToldOf, mergedConfig, queuedSwitchLines, setExitTasks, tasksFor, taskWhatOf } from "./state.js";
12
+ import { taskCounts, taskNoticeLines, taskNoticeRows, tasksForDisplay } from "./task-notice.js";
13
+ import { current as inRunCompaction, takeKept as takeKeptCompaction } from "./in-run-compaction.js";
13
14
  import { echoText } from "@vincemakes/kiso-tui-cells/render";
14
15
  import { canonicalizeUsage, RunClosedError } from "@vincemakes/kiso-runtime";
15
- import { canonicalizeUsageForModel, requestBudget } from "@vincemakes/kiso-runtime/internal";
16
- import { dispatch, abortBangCommand } from "./dispatch.js";
17
- import { paintWindowTitle } from "./window-title.js";
16
+ import { canonicalizeUsageForModel, requestBudget, tiersFor } from "@vincemakes/kiso-runtime/internal";
17
+ import { homedir } from "node:os";
18
+ import { currentBranch } from "./git-branch.js";
19
+ import { dispatch, markAutoCompact, markCompactWhy, abortBangCommand } from "./dispatch.js";
20
+ import { setTitleState } from "./window-title.js";
18
21
  import { agentBaseUrl, agentModel, body, bodyLog, configuredWindow, dock, retryOnRow, retryShown, setRetryShown, floorOn, protectedFiles, upstreamOf, VERSION } from "./state.js";
19
22
  import { attachImages } from "./attachments.js";
20
23
  import { installedVersion, staleVersionNotice } from "./stale-version.js";
@@ -266,6 +269,36 @@ export function displayCtxRatio(session) {
266
269
  return anchored / window;
267
270
  return requestBudget(session.requestParts(), window).ratio;
268
271
  }
272
+ /**
273
+ * Graphite §8.9 — the status bar's facts, one builder for every caller
274
+ * (the boot row, the idle row, the running turn's row), so they cannot
275
+ * drift apart. The context share is USED, with the runtime's own
276
+ * compaction tiers as shares of the same window (`tiersFor`, never a
277
+ * fixed fraction); an unknown window is `ctx ?`.
278
+ */
279
+ export function barFor(session, measured = { tokPerSec: null }) {
280
+ const window = knownContextWindow();
281
+ const used = displayCtxRatio(session);
282
+ const tiers = window === null ? null : tiersFor(window, 0);
283
+ const home = homedir();
284
+ const cwd = process.cwd();
285
+ return {
286
+ // the main-sync round: the tier by its name (#203's MODE_LABEL), full
287
+ // access in the failure colour, and the don't-ask switch as its own chip
288
+ mode: getMode() === "plan" ? "plan \u00b7 read-only" : MODE_LABEL[getMode()],
289
+ modeAlert: getMode() === "full-access",
290
+ dontAsk: getDontAsk(),
291
+ floorOff: !floorOn,
292
+ model: statusModelLabel(session),
293
+ ctx: tiers === null || window === null || !Number.isFinite(used) ? null : { used, soft: tiers.soft / window, hard: tiers.hard / window },
294
+ tokPerSec: measured.tokPerSec,
295
+ // ADR-0058 (3e): the session's tasks — the bar is where they are
296
+ // counted in Graphite (§8.9); absent, the bar is unchanged
297
+ ...(measured.tasks !== undefined ? { tasks: measured.tasks } : {}),
298
+ branch: currentBranch(cwd),
299
+ folder: cwd === home ? "~" : cwd.startsWith(`${home}/`) ? `~${cwd.slice(home.length)}` : cwd,
300
+ };
301
+ }
269
302
  /** 0.40.0 item 9: how long a prompt cache is assumed to live. PROVISIONAL:
270
303
  * DeepSeek does not publish its TTL (the owner's cache was gone after 27
271
304
  * minutes); Anthropic's default is 5 minutes. */
@@ -505,8 +538,8 @@ export function startStatusSpinner(onTick) {
505
538
  setRetryShown(null);
506
539
  if (!dock.active)
507
540
  return () => { };
508
- // v3 §03/§05: the working glyph family ▖▘▝▗, 200ms rotation — the
509
- // callback repaints the running status line with the new glyph.
541
+ // v3 §03/§05: the working glyph family (STATUS_GLYPHS), 200ms
542
+ // rotation — the callback repaints the live row with the new glyph.
510
543
  // KC2 §5: the family itself moved to the tui's status formatters.
511
544
  let i = 0;
512
545
  const timer = setInterval(() => onTick(STATUS_GLYPHS[i++ % STATUS_GLYPHS.length]), 200);
@@ -578,13 +611,15 @@ function approvalDiff(name, input) {
578
611
  }
579
612
  try {
580
613
  if (name === "edit_file") {
581
- const search = typeof input.search === "string" ? input.search : "";
582
- const replace = typeof input.replace === "string" ? input.replace : "";
583
- if (search === "")
614
+ // R2a: the single pair or the batch — a batch's hunks applied in
615
+ // order, as the tool applies them (it reached the panel with no
616
+ // diff while only search/replace was read)
617
+ const hunks = hunksOf(input);
618
+ if (hunks === null || hunks.some((h) => h.search === ""))
584
619
  return null;
585
620
  // TUI2-R1.5 ② (VD-2): the path rides along so a miss can name the
586
621
  // file in its honest note instead of fabricating a diff.
587
- return editFileDiff(oldContent ?? "", search, replace, path);
622
+ return editFileHunksDiff(oldContent ?? "", hunks, path);
588
623
  }
589
624
  const content = typeof input.content === "string" ? input.content : "";
590
625
  return writeFileDiff(oldContent, content);
@@ -919,7 +954,16 @@ onInputLanded) {
919
954
  if (ev.type === "summarized" || ev.type === "microcompacted") {
920
955
  const r = displayCtxRatio(session);
921
956
  const ctx = Number.isFinite(r) ? ` · ctx now ~${Math.round(r * 100)}% used` : "";
922
- body.notice(ev.type === "summarized" ? `✦ compacted mid-run — the conversation before this point is a summary now${ctx}` : `✦ pruned old tool output mid-run${ctx}`);
957
+ const pipe = ev.type === "summarized" ? `✦ compacted mid-run — the conversation before this point is a summary now${ctx}` : `✦ pruned old tool output mid-run${ctx}`;
958
+ // the compaction round (owner, 2026-10-06): the COMPACTED row says
959
+ // the numbers — how big the context was, how big it is now, the
960
+ // share of the window used — where it said a sentence. A pipe keeps
961
+ // the sentence.
962
+ const kept = ev.type === "summarized" ? takeKeptCompaction() : null;
963
+ if (kept !== null)
964
+ body.metaNotice(pipe, [{ label: "COMPACTED", sentence: `mid-run \u00b7 ~${kUnit(kept.pre)} \u2192 ~${kUnit(kept.post)}${Number.isFinite(r) ? ` \u00b7 ctx now ${Math.round(r * 100)}%` : ""}` }]);
965
+ else
966
+ body.notice(pipe);
923
967
  }
924
968
  if (ev.type !== "thinking") {
925
969
  if (thinkingSince !== null) {
@@ -938,13 +982,6 @@ onInputLanded) {
938
982
  inputsSeen += 1;
939
983
  if (inputsSeen > 1)
940
984
  onInputLanded?.();
941
- // The window title is re-derived here because THIS is when a
942
- // session stops being nameless: the tab opened as `kiso —
943
- // <workspace>` and the first substantive prompt is what gives
944
- // it a name. Re-derived rather than set, so the opener rule is
945
- // `sessionTitle`'s and a greeting-first session upgrades when
946
- // the real prompt lands instead of keeping "hi" forever.
947
- paintWindowTitle(session.log.all);
948
985
  // TV-1B: a system-sourced input is PRODUCT MACHINERY — visible
949
986
  // (every durable input renders) but never painted as the
950
987
  // user's words. Provenance is honest on screen, not only in
@@ -953,7 +990,13 @@ onInputLanded) {
953
990
  // any run that carries one, as replay.ts does for old logs.
954
991
  // ADR-0058: a task notice is a row, never the person's chip
955
992
  if (ev.via?.kind === "tasks") {
956
- body.notice(taskNoticeRow(ev.via.items));
993
+ // the tasks round: one row per task, how it ended and what ran —
994
+ // a loss already said (Amendment 8) is left out, as the pipe's
995
+ // lines (deliveryLines) leave it out
996
+ const told = lostToldOf(session.id);
997
+ const shown = ev.via.items.filter((i) => !(i.transition === "unknown" && told.has(i.taskId)));
998
+ if (shown.length > 0)
999
+ body.metaNotice(deliveryLines(session.id, ev.via.items).join("\n"), taskNoticeRows(shown, tasksForDisplay(tasksFor(session.id))));
957
1000
  break;
958
1001
  }
959
1002
  if (ev.source === "system") {
@@ -1196,8 +1239,11 @@ onInputLanded) {
1196
1239
  const e = ev.outcome.error;
1197
1240
  body.notice(`run failed — ${e.code}${e.status !== undefined ? ` ${e.status}` : ""}${e.retryable ? " (retryable)" : ""}: ${escapeTerminal(e.message)}`);
1198
1241
  }
1199
- bodyLog(renderRecap({
1242
+ // Graphite §7.11: the seal is a cell on the terminal (drawn at
1243
+ // the current width, no context share); the pipe keeps the recap.
1244
+ const recapStats = {
1200
1245
  seconds: Math.round((Date.now() - turnStart) / 1000),
1246
+ stopped: ev.outcome.kind === "aborted",
1201
1247
  // R3g: the work terms are NOT passed — the fold line
1202
1248
  // says what the turn did, once, where it happened and
1203
1249
  // with the key that reopens it. This row is the turn's
@@ -1219,12 +1265,18 @@ onInputLanded) {
1219
1265
  // W19: under plan the recap becomes the way-forward row
1220
1266
  // (the /mode hints are the mode's exits).
1221
1267
  mode: getMode(),
1222
- }));
1268
+ };
1269
+ body.seal(sealTiers(recapStats), renderRecap(recapStats));
1223
1270
  break;
1224
1271
  }
1225
1272
  default: {
1226
1273
  // Events without a cell (stop, …) — the generic render, byte-
1227
1274
  // preserved for the pipe path (C5: Event → RenderInput first).
1275
+ // the compaction round (owner, 2026-10-06): on a dock the
1276
+ // COMPACTED row above already says it — `[summarized up to seq
1277
+ // N]` was a raw row under it. A pipe keeps the line.
1278
+ if (ev.type === "summarized" && dock.active)
1279
+ break;
1228
1280
  const input = toRenderInput(ev);
1229
1281
  if (input === null)
1230
1282
  break;
@@ -1334,6 +1386,11 @@ export async function chat(session, faux, input, autoCompact, nav, seed) {
1334
1386
  runGlyph = g;
1335
1387
  paintRunning();
1336
1388
  });
1389
+ // Graphite §8.7: the live row stands from the run's FIRST frame —
1390
+ // the spinner's first tick is 200ms away, and a row that arrived
1391
+ // then would move the content above it once the turn had begun.
1392
+ paintRunning();
1393
+ setTitleState("working"); // §8.10: the title says so, once, not per tick
1337
1394
  (async () => {
1338
1395
  let last;
1339
1396
  try {
@@ -1510,7 +1567,7 @@ export async function chat(session, faux, input, autoCompact, nav, seed) {
1510
1567
  // no rate in the pricing table contributes nothing and the row shows no
1511
1568
  // $ at all, because a partial total presented as a total is a lie.
1512
1569
  let spentUsd = null;
1513
- let runGlyph = "▖";
1570
+ let runGlyph = STATUS_GLYPHS[0];
1514
1571
  let runStart = Date.now();
1515
1572
  // TPS-1: the decode rate of the last SETTLED call. One variable serves
1516
1573
  // both rows because they want the same number at different moments;
@@ -1536,7 +1593,7 @@ export async function chat(session, faux, input, autoCompact, nav, seed) {
1536
1593
  const manager = tasksFor(session.id);
1537
1594
  if (manager === undefined)
1538
1595
  return undefined;
1539
- if (fresh || taskRow === null || Date.now() - taskRow.at > 1_000 || taskRow.seen !== seenUnknownTasks.size) {
1596
+ if (fresh || taskRow === null || Date.now() - taskRow.at > 1_000) {
1540
1597
  let list = [];
1541
1598
  try {
1542
1599
  list = manager.list();
@@ -1544,7 +1601,7 @@ export async function chat(session, faux, input, autoCompact, nav, seed) {
1544
1601
  catch {
1545
1602
  // a corrupt journal is reported where it is read on purpose
1546
1603
  }
1547
- taskRow = { at: Date.now(), seen: seenUnknownTasks.size, counts: taskCounts(list, seenUnknownTasks) };
1604
+ taskRow = { at: Date.now(), counts: taskCounts(list) };
1548
1605
  }
1549
1606
  return taskRow.counts;
1550
1607
  };
@@ -1556,27 +1613,39 @@ export async function chat(session, faux, input, autoCompact, nav, seed) {
1556
1613
  // (DF-0330-F1). A pending retry makes it thirty columns longer, and
1557
1614
  // composed blind it would be cut from the END — the context figure,
1558
1615
  // a fact, going before the gesture hints.
1559
- if (dock.active)
1560
- dock.setStatus(runningStatus(runGlyph, runStart, runUsage.out, displayCtxRatio(session), lastTokPerSec, rowWidth(), retryOnRow(), taskCountsNow(), detachableNow()));
1616
+ // Graphite §8.7 / §8.9: the turn's own facts ride the LIVE row above
1617
+ // the composer; the status bar keeps the session's (its ctx moves as
1618
+ // the turn reads).
1619
+ if (!dock.active)
1620
+ return;
1621
+ // the main-sync round (ADR-0058 3e): the live row teaches ctrl+b exactly
1622
+ // while a command can be moved to the background; the bar counts the
1623
+ // session's tasks (§8.9)
1624
+ // the compaction round (owner, 2026-10-06): a summary inside the run is
1625
+ // the row /compact draws — `compacting · auto`, its progress in the
1626
+ // bar's cells — not `working` with a clock and nothing arriving
1627
+ const summary = inRunCompaction();
1628
+ if (summary !== null) {
1629
+ const W = rowWidth();
1630
+ dock.setLive(liveRow(compactingStatus(runGlyph, summary.rounds, summary.tokens, Math.round((Date.now() - summary.since) / 1000), W - 20, retryOnRow(), summary.progress, "auto"), ["esc stops"], W));
1631
+ return;
1632
+ }
1633
+ dock.setLive(workingRow(runGlyph, runStart, runUsage.out, lastTokPerSec, rowWidth(), retryOnRow(), detachableNow()));
1634
+ dock.setBar(barFor(session, { tokPerSec: lastTokPerSec, tasks: taskCountsNow() }));
1561
1635
  };
1562
1636
  // W19: under plan the idle row makes the posture unmistakable — the W4
1563
1637
  // parentheses idiom names the read-only constraint. The tier is the
1564
1638
  // CALLER's word (the recovery flow passes the bare mode).
1565
1639
  const paintIdle = () => {
1640
+ setTitleState("ready"); // §8.10 — a dock-less TTY has a title too
1566
1641
  if (!dock.active)
1567
1642
  return;
1568
- // TUI2-R1 (E): the meter rides the idle row — both fields omitted
1569
- // when unknown, so a session that has not called the model paints
1570
- // exactly the pre-round row.
1571
- dock.setStatus(idleStatus(modeDisplay(), statusModelLabel(session), displayCtxRatio(session), {
1572
- cacheHitPct: cacheHitPct(runUsage),
1573
- costUsd: spentUsd,
1574
- tokPerSec: lastTokPerSec,
1575
- },
1576
- // DF-0330-F1: the row's budget. Without it the row is composed
1577
- // blind and invariant ① cuts whatever sits last — which is how
1578
- // a measured rate went missing at 100 columns.
1579
- rowWidth(), !floorOn, taskCountsNow(true)));
1643
+ // Graphite §8.9: nothing is live; the bar carries the session and
1644
+ // its health. TUI2-R1 (E): the meter's fields are omitted while
1645
+ // unmeasured, so a session that has not called the model paints no
1646
+ // rate.
1647
+ dock.setLive(null);
1648
+ dock.setBar(barFor(session, { tokPerSec: lastTokPerSec, tasks: taskCountsNow(true) }));
1580
1649
  };
1581
1650
  // TUI2-R2 ⑥ — the BOOT status line. The row is the product's one
1582
1651
  // persistent claim about itself (the tier, how to change it, the model,
@@ -1621,7 +1690,7 @@ export async function chat(session, faux, input, autoCompact, nav, seed) {
1621
1690
  spentUsd = (spentUsd ?? 0) + usd;
1622
1691
  };
1623
1692
  const queueTurn = (line, via) => {
1624
- const slot = { line: via?.kind === "skill" ? via.line : via?.kind === "tasks" ? taskNoticeRow(via.items) : line, content: line, ...(via !== undefined ? { via } : {}), cancelled: false };
1693
+ const slot = { line: via?.kind === "skill" ? via.line : via?.kind === "tasks" ? (deliveryLines(session.id, via.items).join(" · ") || taskNoticeLines(via.items, taskWhatOf(session.id)).join(" · ")) : line, content: line, ...(via !== undefined ? { via } : {}), cancelled: false };
1625
1694
  pendingTurns.push(slot);
1626
1695
  queued += 1;
1627
1696
  chainRef.current = chainRef.current.then(async () => {
@@ -1706,6 +1775,15 @@ export async function chat(session, faux, input, autoCompact, nav, seed) {
1706
1775
  : session.useTasks(taskManager, {
1707
1776
  wake: mergedConfig.taskWake !== false,
1708
1777
  onWake: (w) => queueTurn(w.content, w.via),
1778
+ // Amendment 8: a loss is said when kiso concludes it — the
1779
+ // model's notice still rides the next run, and is not said again
1780
+ onLost: (ids) => {
1781
+ const told = lostToldOf(session.id);
1782
+ for (const id of ids)
1783
+ told.add(id);
1784
+ for (const line of taskNoticeLines(ids.map((taskId) => ({ taskId, transition: "unknown" })), taskWhatOf(session.id)))
1785
+ body.notice(line);
1786
+ },
1709
1787
  ...(Number.isFinite(autoDetachFromEnv) && autoDetachFromEnv >= 0 ? { autoDetachMinAgeMs: autoDetachFromEnv } : {}),
1710
1788
  });
1711
1789
  // 3e: a task's change repaints the row it is counted on
@@ -1814,6 +1892,7 @@ export async function chat(session, faux, input, autoCompact, nav, seed) {
1814
1892
  const ratio = autoCompactRatio(session); // A1a: the policy keeps the pre-A1a number — A1b decides if it moves
1815
1893
  if (!Number.isFinite(ratio) || ratio < autoCompact.thresholdRatio)
1816
1894
  return;
1895
+ markAutoCompact();
1817
1896
  dispatch("/compact", dispatchCtx);
1818
1897
  };
1819
1898
  // CX-1 F5: a literal input (task-file mode) submits the turn directly —
@@ -1847,14 +1926,16 @@ export async function chat(session, faux, input, autoCompact, nav, seed) {
1847
1926
  // The switch is the SAME live-extension flip /mode performs; the status
1848
1927
  // row repaints at once with a one-line notice.
1849
1928
  input.onModeCycle?.(() => {
1850
- const next = OFFERED_MODES[(OFFERED_MODES.indexOf(getMode()) + 1) % OFFERED_MODES.length];
1929
+ const was = getMode();
1930
+ const next = OFFERED_MODES[(OFFERED_MODES.indexOf(was) + 1) % OFFERED_MODES.length];
1851
1931
  const wasOn = getDontAsk();
1852
1932
  setMode(next);
1853
1933
  paintIdle();
1854
- body.notice(`mode → ${MODE_LABEL[next]} (shift+tab cycles)`);
1934
+ // Graphite R3e: the MODE row on a terminal (the pipe keeps these words)
1935
+ body.modeNotice(`mode → ${MODE_LABEL[next]} (shift+tab cycles)`, MODE_LABEL[was], MODE_LABEL[next]);
1855
1936
  // the switch came with the old dontAsk tier, and left with it
1856
1937
  if (getDontAsk() !== wasOn)
1857
- body.notice("don't ask → off");
1938
+ body.dontAskNotice("don't ask → off", false);
1858
1939
  });
1859
1940
  // Recovery first: a session with a dangling pause or uncertain
1860
1941
  // executions must resolve them BEFORE the REPL accepts new turns —
@@ -1876,11 +1957,17 @@ export async function chat(session, faux, input, autoCompact, nav, seed) {
1876
1957
  // before the run resumes: the run continues on the compacted projection.
1877
1958
  const cold = !cancelled && process.stdin.isTTY ? coldResumeOffer(session) : null;
1878
1959
  if (cold !== null) {
1960
+ // Graphite P1b (owner, 2026-09-30): either way the compaction row
1961
+ // says why it runs — `cold cache` — so on a dock dontAsk needs no line
1962
+ // of its own; off a dock the line is printed as it always was
1879
1963
  if (getDontAsk()) {
1880
- body.notice(`[dontAsk] ${coldResumeLine(cold.tokens, cold.minutes)} — compacting first`);
1964
+ if (!dock.active)
1965
+ body.notice(`[dontAsk] ${coldResumeLine(cold.tokens, cold.minutes)} — compacting first`);
1966
+ markCompactWhy("cold cache");
1881
1967
  dispatch("/compact", dispatchCtx);
1882
1968
  }
1883
1969
  else if ((await askPanel(input, coldResumeView(cold.tokens, cold.minutes))).action === "allow") {
1970
+ markCompactWhy("cold cache");
1884
1971
  dispatch("/compact", dispatchCtx);
1885
1972
  }
1886
1973
  await chainRef.current;
@@ -24,6 +24,13 @@ export declare const SYSTEM_PROMPT = "You are kiso, a coding agent. You work in
24
24
  * a protected one is skipped as though absent.
25
25
  */
26
26
  export declare function readProjectInstructions(cwd: string, protectedFiles?: readonly string[]): string;
27
+ /** The instruction file the prompt reads, and its text — the ONE lookup,
28
+ * so the opening's RULES fact (Graphite §7.10) names exactly the file the
29
+ * model is given, never a file it is not. */
30
+ export declare function projectInstructions(cwd: string, protectedFiles?: readonly string[]): {
31
+ readonly name: string;
32
+ readonly text: string;
33
+ } | null;
27
34
  /** A area: the session's system prompt — the constant plus any project
28
35
  * instructions found in the workspace. Deterministic per cwd. */
29
36
  export declare function composeSystemPrompt(cwd: string, protectedFiles?: readonly string[]): string;
@@ -64,20 +64,28 @@ const INSTRUCTION_FILES = ["AGENTS.md", "CLAUDE.md"];
64
64
  * a protected one is skipped as though absent.
65
65
  */
66
66
  export function readProjectInstructions(cwd, protectedFiles = []) {
67
+ const found = projectInstructions(cwd, protectedFiles);
68
+ if (found === null)
69
+ return "";
70
+ const { name, text } = found;
71
+ return `\n\n=== Project instructions (${name}) ===\n${text.length > 8 * 1024 ? text.slice(0, 8 * 1024) + `\n\n[truncated at ${8 * 1024} chars]` : text}`;
72
+ }
73
+ /** The instruction file the prompt reads, and its text — the ONE lookup,
74
+ * so the opening's RULES fact (Graphite §7.10) names exactly the file the
75
+ * model is given, never a file it is not. */
76
+ export function projectInstructions(cwd, protectedFiles = []) {
67
77
  const id = protectedIdentity(protectedFiles);
68
78
  for (const name of INSTRUCTION_FILES) {
69
79
  if (isProtectedPath(join(cwd, name), id))
70
80
  continue;
71
- let text;
72
81
  try {
73
- text = readFileSync(join(cwd, name), "utf8");
82
+ return { name, text: readFileSync(join(cwd, name), "utf8") };
74
83
  }
75
84
  catch {
76
85
  continue; // not present — try the next
77
86
  }
78
- return `\n\n=== Project instructions (${name}) ===\n${text.length > 8 * 1024 ? text.slice(0, 8 * 1024) + `\n\n[truncated at ${8 * 1024} chars]` : text}`;
79
87
  }
80
- return "";
88
+ return null;
81
89
  }
82
90
  /** A area: the session's system prompt — the constant plus any project
83
91
  * instructions found in the workspace. Deterministic per cwd. */
package/dist/config.d.ts CHANGED
@@ -151,7 +151,8 @@ export declare function loadUserConfig(): KisoConfig | null;
151
151
  /** The project config — read ONLY when the project's .kiso passed the E3
152
152
  * trust gate (an untrusted project's config is never even read). */
153
153
  export declare function loadProjectConfig(cwd: string, trusted: boolean): KisoConfig | null;
154
- /** Merge: project wins over user (each layer's own keys only). */
154
+ /** Merge: a project's keys win over the user's (each layer's own keys
155
+ * only) — except that a project may only tighten (assertProjectTightens). */
155
156
  export declare function mergeConfigs(user: KisoConfig | null, project: KisoConfig | null): KisoConfig;
156
157
  /** What a profile's sign-in actually IS. The two shapes are not
157
158
  * interchangeable: an API key is a string an adapter holds, an OAuth
@@ -210,6 +211,9 @@ export declare function resolveContextWindow(merged: KisoConfig, profile?: Model
210
211
  /** Auto-compact: env (KISO_AUTO_COMPACT) > config.autoCompact > off. An
211
212
  * invalid env value is OFF (it set the env → it wins, and it is invalid). */
212
213
  export declare function resolveAutoCompact(merged: KisoConfig): AutoCompactConfig | undefined;
213
- /** Project-trust policy: config.projectTrust (project wins over user) —
214
- * "ask" (the E3 gate, default) or "never" (the gate auto-refuses). */
214
+ /** Project-trust policy: the USER config's projectTrust — "ask" (the E3
215
+ * gate, default) or "never" (the gate auto-refuses). The gate passes
216
+ * loadUserConfig() alone (trust-ui.ts): a project never relaxes its own
217
+ * gate, and it could not anyway, since an untrusted project's config is
218
+ * never read. */
215
219
  export declare function resolveProjectTrustPolicy(merged: KisoConfig): "ask" | "never";