@vincemakes/kiso-code 0.46.2 → 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.
@@ -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") {
@@ -1197,8 +1239,11 @@ onInputLanded) {
1197
1239
  const e = ev.outcome.error;
1198
1240
  body.notice(`run failed — ${e.code}${e.status !== undefined ? ` ${e.status}` : ""}${e.retryable ? " (retryable)" : ""}: ${escapeTerminal(e.message)}`);
1199
1241
  }
1200
- 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 = {
1201
1245
  seconds: Math.round((Date.now() - turnStart) / 1000),
1246
+ stopped: ev.outcome.kind === "aborted",
1202
1247
  // R3g: the work terms are NOT passed — the fold line
1203
1248
  // says what the turn did, once, where it happened and
1204
1249
  // with the key that reopens it. This row is the turn's
@@ -1220,12 +1265,18 @@ onInputLanded) {
1220
1265
  // W19: under plan the recap becomes the way-forward row
1221
1266
  // (the /mode hints are the mode's exits).
1222
1267
  mode: getMode(),
1223
- }));
1268
+ };
1269
+ body.seal(sealTiers(recapStats), renderRecap(recapStats));
1224
1270
  break;
1225
1271
  }
1226
1272
  default: {
1227
1273
  // Events without a cell (stop, …) — the generic render, byte-
1228
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;
1229
1280
  const input = toRenderInput(ev);
1230
1281
  if (input === null)
1231
1282
  break;
@@ -1335,6 +1386,11 @@ export async function chat(session, faux, input, autoCompact, nav, seed) {
1335
1386
  runGlyph = g;
1336
1387
  paintRunning();
1337
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
1338
1394
  (async () => {
1339
1395
  let last;
1340
1396
  try {
@@ -1511,7 +1567,7 @@ export async function chat(session, faux, input, autoCompact, nav, seed) {
1511
1567
  // no rate in the pricing table contributes nothing and the row shows no
1512
1568
  // $ at all, because a partial total presented as a total is a lie.
1513
1569
  let spentUsd = null;
1514
- let runGlyph = "▖";
1570
+ let runGlyph = STATUS_GLYPHS[0];
1515
1571
  let runStart = Date.now();
1516
1572
  // TPS-1: the decode rate of the last SETTLED call. One variable serves
1517
1573
  // both rows because they want the same number at different moments;
@@ -1557,27 +1613,39 @@ export async function chat(session, faux, input, autoCompact, nav, seed) {
1557
1613
  // (DF-0330-F1). A pending retry makes it thirty columns longer, and
1558
1614
  // composed blind it would be cut from the END — the context figure,
1559
1615
  // 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()));
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() }));
1562
1635
  };
1563
1636
  // W19: under plan the idle row makes the posture unmistakable — the W4
1564
1637
  // parentheses idiom names the read-only constraint. The tier is the
1565
1638
  // CALLER's word (the recovery flow passes the bare mode).
1566
1639
  const paintIdle = () => {
1640
+ setTitleState("ready"); // §8.10 — a dock-less TTY has a title too
1567
1641
  if (!dock.active)
1568
1642
  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)));
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) }));
1581
1649
  };
1582
1650
  // TUI2-R2 ⑥ — the BOOT status line. The row is the product's one
1583
1651
  // persistent claim about itself (the tier, how to change it, the model,
@@ -1824,6 +1892,7 @@ export async function chat(session, faux, input, autoCompact, nav, seed) {
1824
1892
  const ratio = autoCompactRatio(session); // A1a: the policy keeps the pre-A1a number — A1b decides if it moves
1825
1893
  if (!Number.isFinite(ratio) || ratio < autoCompact.thresholdRatio)
1826
1894
  return;
1895
+ markAutoCompact();
1827
1896
  dispatch("/compact", dispatchCtx);
1828
1897
  };
1829
1898
  // CX-1 F5: a literal input (task-file mode) submits the turn directly —
@@ -1857,14 +1926,16 @@ export async function chat(session, faux, input, autoCompact, nav, seed) {
1857
1926
  // The switch is the SAME live-extension flip /mode performs; the status
1858
1927
  // row repaints at once with a one-line notice.
1859
1928
  input.onModeCycle?.(() => {
1860
- 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];
1861
1931
  const wasOn = getDontAsk();
1862
1932
  setMode(next);
1863
1933
  paintIdle();
1864
- 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]);
1865
1936
  // the switch came with the old dontAsk tier, and left with it
1866
1937
  if (getDontAsk() !== wasOn)
1867
- body.notice("don't ask → off");
1938
+ body.dontAskNotice("don't ask → off", false);
1868
1939
  });
1869
1940
  // Recovery first: a session with a dangling pause or uncertain
1870
1941
  // executions must resolve them BEFORE the REPL accepts new turns —
@@ -1886,11 +1957,17 @@ export async function chat(session, faux, input, autoCompact, nav, seed) {
1886
1957
  // before the run resumes: the run continues on the compacted projection.
1887
1958
  const cold = !cancelled && process.stdin.isTTY ? coldResumeOffer(session) : null;
1888
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
1889
1963
  if (getDontAsk()) {
1890
- 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");
1891
1967
  dispatch("/compact", dispatchCtx);
1892
1968
  }
1893
1969
  else if ((await askPanel(input, coldResumeView(cold.tokens, cold.minutes))).action === "allow") {
1970
+ markCompactWhy("cold cache");
1894
1971
  dispatch("/compact", dispatchCtx);
1895
1972
  }
1896
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. */
@@ -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;