@vincemakes/kiso-code 0.46.2 → 0.47.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.
@@ -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
11
  import { deliveryLines, liveTasks, lostToldOf, mergedConfig, queuedSwitchLines, setExitTasks, tasksFor, taskWhatOf } from "./state.js";
12
- import { taskCounts, taskNoticeLines } from "./task-notice.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,8 +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
- for (const row of deliveryLines(session.id, ev.via.items))
957
- body.notice(row);
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))));
958
1000
  break;
959
1001
  }
960
1002
  if (ev.source === "system") {
@@ -1108,7 +1150,12 @@ onInputLanded) {
1108
1150
  case "cancel": {
1109
1151
  // round 10: a cancellation is a CONSERVATIVE denial,
1110
1152
  // explicitly distinguished from the user typing "n".
1111
- body.notice("[approval cancelled — treated as a denial]");
1153
+ // 0.47.1 (finding 0470-F4, owner 2026-10-07): on a dock
1154
+ // esc IS the panel's deny key (`esc denies`), and the
1155
+ // card says `denied by you` — this row said it a third
1156
+ // time. A pipe keeps it.
1157
+ if (!dock.active)
1158
+ body.notice("[approval cancelled — treated as a denial]");
1112
1159
  await session.approve(decisionId, false);
1113
1160
  break;
1114
1161
  }
@@ -1197,8 +1244,11 @@ onInputLanded) {
1197
1244
  const e = ev.outcome.error;
1198
1245
  body.notice(`run failed — ${e.code}${e.status !== undefined ? ` ${e.status}` : ""}${e.retryable ? " (retryable)" : ""}: ${escapeTerminal(e.message)}`);
1199
1246
  }
1200
- bodyLog(renderRecap({
1247
+ // Graphite §7.11: the seal is a cell on the terminal (drawn at
1248
+ // the current width, no context share); the pipe keeps the recap.
1249
+ const recapStats = {
1201
1250
  seconds: Math.round((Date.now() - turnStart) / 1000),
1251
+ stopped: ev.outcome.kind === "aborted",
1202
1252
  // R3g: the work terms are NOT passed — the fold line
1203
1253
  // says what the turn did, once, where it happened and
1204
1254
  // with the key that reopens it. This row is the turn's
@@ -1220,12 +1270,18 @@ onInputLanded) {
1220
1270
  // W19: under plan the recap becomes the way-forward row
1221
1271
  // (the /mode hints are the mode's exits).
1222
1272
  mode: getMode(),
1223
- }));
1273
+ };
1274
+ body.seal(sealTiers(recapStats), renderRecap(recapStats));
1224
1275
  break;
1225
1276
  }
1226
1277
  default: {
1227
1278
  // Events without a cell (stop, …) — the generic render, byte-
1228
1279
  // preserved for the pipe path (C5: Event → RenderInput first).
1280
+ // the compaction round (owner, 2026-10-06): on a dock the
1281
+ // COMPACTED row above already says it — `[summarized up to seq
1282
+ // N]` was a raw row under it. A pipe keeps the line.
1283
+ if (ev.type === "summarized" && dock.active)
1284
+ break;
1229
1285
  const input = toRenderInput(ev);
1230
1286
  if (input === null)
1231
1287
  break;
@@ -1335,6 +1391,11 @@ export async function chat(session, faux, input, autoCompact, nav, seed) {
1335
1391
  runGlyph = g;
1336
1392
  paintRunning();
1337
1393
  });
1394
+ // Graphite §8.7: the live row stands from the run's FIRST frame —
1395
+ // the spinner's first tick is 200ms away, and a row that arrived
1396
+ // then would move the content above it once the turn had begun.
1397
+ paintRunning();
1398
+ setTitleState("working"); // §8.10: the title says so, once, not per tick
1338
1399
  (async () => {
1339
1400
  let last;
1340
1401
  try {
@@ -1511,7 +1572,7 @@ export async function chat(session, faux, input, autoCompact, nav, seed) {
1511
1572
  // no rate in the pricing table contributes nothing and the row shows no
1512
1573
  // $ at all, because a partial total presented as a total is a lie.
1513
1574
  let spentUsd = null;
1514
- let runGlyph = "▖";
1575
+ let runGlyph = STATUS_GLYPHS[0];
1515
1576
  let runStart = Date.now();
1516
1577
  // TPS-1: the decode rate of the last SETTLED call. One variable serves
1517
1578
  // both rows because they want the same number at different moments;
@@ -1557,27 +1618,39 @@ export async function chat(session, faux, input, autoCompact, nav, seed) {
1557
1618
  // (DF-0330-F1). A pending retry makes it thirty columns longer, and
1558
1619
  // composed blind it would be cut from the END — the context figure,
1559
1620
  // a fact, going before the gesture hints.
1560
- if (dock.active)
1561
- dock.setStatus(runningStatus(runGlyph, runStart, runUsage.out, displayCtxRatio(session), lastTokPerSec, rowWidth(), retryOnRow(), taskCountsNow(), detachableNow()));
1621
+ // Graphite §8.7 / §8.9: the turn's own facts ride the LIVE row above
1622
+ // the composer; the status bar keeps the session's (its ctx moves as
1623
+ // the turn reads).
1624
+ if (!dock.active)
1625
+ return;
1626
+ // the main-sync round (ADR-0058 3e): the live row teaches ctrl+b exactly
1627
+ // while a command can be moved to the background; the bar counts the
1628
+ // session's tasks (§8.9)
1629
+ // the compaction round (owner, 2026-10-06): a summary inside the run is
1630
+ // the row /compact draws — `compacting · auto`, its progress in the
1631
+ // bar's cells — not `working` with a clock and nothing arriving
1632
+ const summary = inRunCompaction();
1633
+ if (summary !== null) {
1634
+ const W = rowWidth();
1635
+ 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));
1636
+ return;
1637
+ }
1638
+ dock.setLive(workingRow(runGlyph, runStart, runUsage.out, lastTokPerSec, rowWidth(), retryOnRow(), detachableNow()));
1639
+ dock.setBar(barFor(session, { tokPerSec: lastTokPerSec, tasks: taskCountsNow() }));
1562
1640
  };
1563
1641
  // W19: under plan the idle row makes the posture unmistakable — the W4
1564
1642
  // parentheses idiom names the read-only constraint. The tier is the
1565
1643
  // CALLER's word (the recovery flow passes the bare mode).
1566
1644
  const paintIdle = () => {
1645
+ setTitleState("ready"); // §8.10 — a dock-less TTY has a title too
1567
1646
  if (!dock.active)
1568
1647
  return;
1569
- // TUI2-R1 (E): the meter rides the idle row — both fields omitted
1570
- // when unknown, so a session that has not called the model paints
1571
- // exactly the pre-round row.
1572
- dock.setStatus(idleStatus(modeDisplay(), statusModelLabel(session), displayCtxRatio(session), {
1573
- cacheHitPct: cacheHitPct(runUsage),
1574
- costUsd: spentUsd,
1575
- tokPerSec: lastTokPerSec,
1576
- },
1577
- // DF-0330-F1: the row's budget. Without it the row is composed
1578
- // blind and invariant ① cuts whatever sits last — which is how
1579
- // a measured rate went missing at 100 columns.
1580
- rowWidth(), !floorOn, taskCountsNow(true)));
1648
+ // Graphite §8.9: nothing is live; the bar carries the session and
1649
+ // its health. TUI2-R1 (E): the meter's fields are omitted while
1650
+ // unmeasured, so a session that has not called the model paints no
1651
+ // rate.
1652
+ dock.setLive(null);
1653
+ dock.setBar(barFor(session, { tokPerSec: lastTokPerSec, tasks: taskCountsNow(true) }));
1581
1654
  };
1582
1655
  // TUI2-R2 ⑥ — the BOOT status line. The row is the product's one
1583
1656
  // persistent claim about itself (the tier, how to change it, the model,
@@ -1824,6 +1897,7 @@ export async function chat(session, faux, input, autoCompact, nav, seed) {
1824
1897
  const ratio = autoCompactRatio(session); // A1a: the policy keeps the pre-A1a number — A1b decides if it moves
1825
1898
  if (!Number.isFinite(ratio) || ratio < autoCompact.thresholdRatio)
1826
1899
  return;
1900
+ markAutoCompact();
1827
1901
  dispatch("/compact", dispatchCtx);
1828
1902
  };
1829
1903
  // CX-1 F5: a literal input (task-file mode) submits the turn directly —
@@ -1857,14 +1931,16 @@ export async function chat(session, faux, input, autoCompact, nav, seed) {
1857
1931
  // The switch is the SAME live-extension flip /mode performs; the status
1858
1932
  // row repaints at once with a one-line notice.
1859
1933
  input.onModeCycle?.(() => {
1860
- const next = OFFERED_MODES[(OFFERED_MODES.indexOf(getMode()) + 1) % OFFERED_MODES.length];
1934
+ const was = getMode();
1935
+ const next = OFFERED_MODES[(OFFERED_MODES.indexOf(was) + 1) % OFFERED_MODES.length];
1861
1936
  const wasOn = getDontAsk();
1862
1937
  setMode(next);
1863
1938
  paintIdle();
1864
- body.notice(`mode → ${MODE_LABEL[next]} (shift+tab cycles)`);
1939
+ // Graphite R3e: the MODE row on a terminal (the pipe keeps these words)
1940
+ body.modeNotice(`mode → ${MODE_LABEL[next]} (shift+tab cycles)`, MODE_LABEL[was], MODE_LABEL[next]);
1865
1941
  // the switch came with the old dontAsk tier, and left with it
1866
1942
  if (getDontAsk() !== wasOn)
1867
- body.notice("don't ask → off");
1943
+ body.dontAskNotice("don't ask → off", false);
1868
1944
  });
1869
1945
  // Recovery first: a session with a dangling pause or uncertain
1870
1946
  // executions must resolve them BEFORE the REPL accepts new turns —
@@ -1886,11 +1962,17 @@ export async function chat(session, faux, input, autoCompact, nav, seed) {
1886
1962
  // before the run resumes: the run continues on the compacted projection.
1887
1963
  const cold = !cancelled && process.stdin.isTTY ? coldResumeOffer(session) : null;
1888
1964
  if (cold !== null) {
1965
+ // Graphite P1b (owner, 2026-09-30): either way the compaction row
1966
+ // says why it runs — `cold cache` — so on a dock dontAsk needs no line
1967
+ // of its own; off a dock the line is printed as it always was
1889
1968
  if (getDontAsk()) {
1890
- body.notice(`[dontAsk] ${coldResumeLine(cold.tokens, cold.minutes)} — compacting first`);
1969
+ if (!dock.active)
1970
+ body.notice(`[dontAsk] ${coldResumeLine(cold.tokens, cold.minutes)} — compacting first`);
1971
+ markCompactWhy("cold cache");
1891
1972
  dispatch("/compact", dispatchCtx);
1892
1973
  }
1893
1974
  else if ((await askPanel(input, coldResumeView(cold.tokens, cold.minutes))).action === "allow") {
1975
+ markCompactWhy("cold cache");
1894
1976
  dispatch("/compact", dispatchCtx);
1895
1977
  }
1896
1978
  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. */
@@ -7,7 +7,8 @@
7
7
  * Moved verbatim from index.ts's makeAgent (R1, 2026-09-23) and renamed;
8
8
  * the body is not edited beyond the rename and activeStoreDir's setter.
9
9
  */
10
- import { activeStoreDir, childTurnBudget, setActiveStoreDir, body, bodyLog, codingToolOptions, extensionsDir, floorOn, kisoHome, loadedExtensions, ownSessionsDir, projectRoot, protectedFiles, secretEnvNamesOf, sessionsDir, setAgentModel, setConfigModels, setConfiguredWindow, setCurrentAgentExtensions, setCurrentFaux, setCurrentModelName, setCurrentProfileName, setExtensionLists, setFloorOn, setMergedConfig, setModelChoice, setNeverInherited, setRetryShown, setSessionStore, setUserProtectedPaths, settingsLayers, workspaceRoot } from "./state.js";
10
+ import { onInRunSummary } from "./in-run-compaction.js";
11
+ import { activeStoreDir, childTurnBudget, setActiveStoreDir, body, bodyLog, dock, codingToolOptions, extensionsDir, floorOn, kisoHome, loadedExtensions, ownSessionsDir, projectRoot, protectedFiles, secretEnvNamesOf, sessionsDir, setAgentModel, setConfigModels, setConfiguredWindow, setCurrentAgentExtensions, setCurrentFaux, setCurrentModelName, setCurrentProfileName, setExtensionLists, setFloorOn, setMergedConfig, setModelChoice, setNeverInherited, setRetryShown, setSessionStore, setUserProtectedPaths, settingsLayers, workspaceRoot } from "./state.js";
11
12
  import { claimProjectDir, projectLayoutActive } from "./projects.js";
12
13
  import { migrationNotice, pendingLegacyIds, planMigration, runMigration } from "./session-migration.js";
13
14
  import { askUi, resolveProjectTrust } from "./trust-ui.js";
@@ -202,7 +203,12 @@ export async function createCodingAgent(sessionId, input, modelFlag) {
202
203
  setConfiguredWindow(resolveContextWindow(merged, resolved?.profile));
203
204
  const model = resolved === null ? "faux" : resolved.profile.model;
204
205
  if (resolved === null) {
205
- console.log("[faux mode — set ANTHROPIC_API_KEY or OPENAI_API_KEY, or configure models in ~/.kiso/config.json]\n");
206
+ // the last sweep (owner, 2026-10-06): on a dock this line was written
207
+ // into the frame and painted over at once — the opening's MODEL row
208
+ // says it instead (opening.ts). A pipe, and -p, keep it.
209
+ if (!dock.active) {
210
+ console.log("[faux mode — set ANTHROPIC_API_KEY or OPENAI_API_KEY, or configure models in ~/.kiso/config.json]\n");
211
+ }
206
212
  setCurrentFaux(true);
207
213
  setCurrentModelName("faux");
208
214
  setCurrentProfileName(null);
@@ -302,6 +308,9 @@ export async function createCodingAgent(sessionId, input, modelFlag) {
302
308
  isCheck: (command) => runsACheck(command, Object.values(merged.checks ?? {})),
303
309
  // ADR-0055 Amendment 2: a discarded checkpoint says so, in sizes only.
304
310
  onDiscard: (d) => body.notice(compactionDiscardedNotice(d)),
311
+ // the compaction round (owner, 2026-10-06): a summary inside a run
312
+ // is drawn while it runs — the running row reads it
313
+ onSummary: onInRunSummary,
305
314
  // ADR-0055 Amendment 2 (decision 3): only a window someone stated
306
315
  // arms the overflow belt — read from the LIVE binding, so it moves
307
316
  // with /model and /resume; the fallback reads as null.
@@ -69,4 +69,6 @@ export declare function parseBang(trimmed: string): {
69
69
  readonly command: string;
70
70
  readonly send: boolean;
71
71
  } | null;
72
+ export declare function markCompactWhy(why: string): void;
73
+ export declare function markAutoCompact(): void;
72
74
  export declare function dispatch(line: string, ctx: DispatchCtx): void;