agent-sh 0.12.26 → 0.13.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 (144) hide show
  1. package/README.md +13 -2
  2. package/dist/agent/agent-loop.d.ts +3 -5
  3. package/dist/agent/agent-loop.js +44 -100
  4. package/dist/agent/conversation-state.d.ts +9 -0
  5. package/dist/agent/conversation-state.js +38 -1
  6. package/dist/agent/history-file.d.ts +6 -0
  7. package/dist/agent/history-file.js +1 -1
  8. package/dist/agent/host-types.d.ts +125 -0
  9. package/dist/agent/index.d.ts +12 -4
  10. package/dist/agent/index.js +357 -6
  11. package/dist/agent/nuclear-form.d.ts +7 -0
  12. package/dist/{extensions → agent}/providers/deepseek.d.ts +2 -2
  13. package/dist/{extensions → agent}/providers/deepseek.js +5 -4
  14. package/dist/{extensions → agent}/providers/openai-compatible.d.ts +2 -2
  15. package/dist/{extensions → agent}/providers/openai.d.ts +2 -2
  16. package/dist/{extensions → agent}/providers/openai.js +3 -2
  17. package/dist/{extensions → agent}/providers/openrouter.d.ts +2 -2
  18. package/dist/{extensions → agent}/providers/openrouter.js +4 -3
  19. package/dist/agent/skills.js +51 -7
  20. package/dist/agent/subagent.d.ts +1 -1
  21. package/dist/agent/system-prompt.js +14 -17
  22. package/dist/agent/tool-protocol.d.ts +1 -1
  23. package/dist/agent/tool-protocol.js +5 -3
  24. package/dist/agent/tool-registry.d.ts +9 -4
  25. package/dist/agent/tool-registry.js +27 -4
  26. package/dist/agent/tools/bash.d.ts +1 -1
  27. package/dist/agent/tools/bash.js +3 -2
  28. package/dist/agent/tools/edit-file.js +0 -1
  29. package/dist/agent/tools/glob.js +1 -1
  30. package/dist/agent/tools/grep.js +1 -1
  31. package/dist/agent/tools/pwsh.d.ts +1 -1
  32. package/dist/agent/tools/pwsh.js +1 -2
  33. package/dist/agent/tools/read-file.js +7 -4
  34. package/dist/agent/tools/write-file.js +0 -1
  35. package/dist/agent/types.d.ts +17 -2
  36. package/dist/cli/auth/cli.d.ts +1 -0
  37. package/dist/cli/auth/cli.js +216 -0
  38. package/dist/cli/auth/keys.d.ts +31 -0
  39. package/dist/cli/auth/keys.js +102 -0
  40. package/dist/{index.js → cli/index.js} +29 -32
  41. package/dist/{init.js → cli/init.js} +1 -1
  42. package/dist/{install.js → cli/install.js} +114 -5
  43. package/dist/cli/subcommands.d.ts +1 -0
  44. package/dist/cli/subcommands.js +17 -0
  45. package/dist/{event-bus.d.ts → core/event-bus.d.ts} +7 -13
  46. package/dist/{extension-loader.d.ts → core/extension-loader.d.ts} +1 -1
  47. package/dist/{extension-loader.js → core/extension-loader.js} +62 -70
  48. package/dist/{core.d.ts → core/index.d.ts} +18 -15
  49. package/dist/{core.js → core/index.js} +18 -92
  50. package/dist/{settings.d.ts → core/settings.d.ts} +7 -0
  51. package/dist/{settings.js → core/settings.js} +1 -0
  52. package/dist/core/types.d.ts +49 -0
  53. package/dist/core/types.js +1 -0
  54. package/dist/extensions/file-autocomplete.d.ts +1 -1
  55. package/dist/extensions/index.d.ts +7 -14
  56. package/dist/extensions/index.js +2 -19
  57. package/dist/extensions/slash-commands.d.ts +1 -1
  58. package/dist/extensions/slash-commands.js +7 -2
  59. package/dist/shell/host-types.d.ts +114 -0
  60. package/dist/shell/host-types.js +1 -0
  61. package/dist/shell/index.d.ts +8 -7
  62. package/dist/shell/index.js +58 -9
  63. package/dist/shell/input-handler.d.ts +7 -1
  64. package/dist/shell/input-handler.js +5 -2
  65. package/dist/shell/output-parser.d.ts +1 -1
  66. package/dist/{extensions → shell}/shell-context.d.ts +1 -1
  67. package/dist/{extensions → shell}/shell-context.js +18 -12
  68. package/dist/shell/shell.d.ts +6 -4
  69. package/dist/shell/shell.js +33 -109
  70. package/dist/shell/strategies/bash.d.ts +2 -0
  71. package/dist/shell/strategies/bash.js +68 -0
  72. package/dist/shell/strategies/fish.d.ts +2 -0
  73. package/dist/shell/strategies/fish.js +65 -0
  74. package/dist/shell/strategies/index.d.ts +13 -0
  75. package/dist/shell/strategies/index.js +17 -0
  76. package/dist/shell/strategies/types.d.ts +50 -0
  77. package/dist/shell/strategies/types.js +9 -0
  78. package/dist/shell/strategies/zsh.d.ts +2 -0
  79. package/dist/shell/strategies/zsh.js +72 -0
  80. package/dist/shell/tui-input-view.js +14 -3
  81. package/dist/{extensions → shell}/tui-renderer.d.ts +1 -1
  82. package/dist/{extensions → shell}/tui-renderer.js +27 -55
  83. package/dist/utils/box-frame.d.ts +4 -0
  84. package/dist/utils/box-frame.js +17 -6
  85. package/dist/utils/compositor.d.ts +1 -1
  86. package/dist/utils/compositor.js +2 -1
  87. package/dist/{executor.js → utils/executor.js} +1 -1
  88. package/dist/utils/floating-panel.d.ts +17 -5
  89. package/dist/utils/floating-panel.js +218 -70
  90. package/dist/utils/llm-facade.d.ts +7 -3
  91. package/dist/utils/stream-transform.d.ts +1 -1
  92. package/dist/utils/terminal-buffer.d.ts +1 -1
  93. package/dist/utils/tool-display.js +4 -0
  94. package/dist/utils/tool-interactive.d.ts +1 -1
  95. package/dist/utils/tty.d.ts +7 -0
  96. package/dist/utils/tty.js +15 -0
  97. package/examples/extensions/ash-acp-bridge/README.md +4 -1
  98. package/examples/extensions/ash-acp-bridge/src/index.ts +654 -0
  99. package/examples/extensions/ash-mcp-bridge/index.ts +1 -1
  100. package/examples/extensions/ashi/README.md +250 -0
  101. package/examples/extensions/ashi/package.json +60 -0
  102. package/examples/extensions/ashi/src/autocomplete.ts +91 -0
  103. package/examples/extensions/ashi/src/capture.ts +34 -0
  104. package/examples/extensions/ashi/src/cli.ts +126 -0
  105. package/examples/extensions/ashi/src/commands.ts +82 -0
  106. package/examples/extensions/ashi/src/compaction.ts +157 -0
  107. package/examples/extensions/ashi/src/components.ts +332 -0
  108. package/examples/extensions/ashi/src/default-renderers.ts +153 -0
  109. package/examples/extensions/ashi/src/display-config.ts +62 -0
  110. package/examples/extensions/ashi/src/frontend.ts +735 -0
  111. package/examples/extensions/ashi/src/hooks.ts +136 -0
  112. package/examples/extensions/ashi/src/multi-session-store.ts +146 -0
  113. package/examples/extensions/ashi/src/session-commands.ts +76 -0
  114. package/examples/extensions/ashi/src/session-store.ts +264 -0
  115. package/examples/extensions/ashi/src/status-footer.ts +66 -0
  116. package/examples/extensions/ashi/src/theme.ts +151 -0
  117. package/examples/extensions/ashi/tsconfig.json +14 -0
  118. package/examples/extensions/emacs-buffer.ts +364 -0
  119. package/examples/extensions/interactive-prompts.ts +114 -69
  120. package/examples/extensions/latex-images.ts +3 -3
  121. package/examples/extensions/opencode-bridge/index.ts +1 -1
  122. package/examples/extensions/overlay-agent.ts +35 -10
  123. package/examples/extensions/peer-mesh.ts +1 -1
  124. package/examples/extensions/pi-bridge/index.ts +0 -1
  125. package/examples/extensions/questionnaire.ts +2 -1
  126. package/examples/extensions/rtk-proxy.ts +3 -3
  127. package/examples/extensions/solarized-theme.ts +3 -3
  128. package/examples/extensions/subagents.ts +6 -6
  129. package/examples/extensions/terminal-buffer.ts +174 -33
  130. package/examples/extensions/tmux-pane.ts +6 -4
  131. package/examples/extensions/tunnel-vision.ts +405 -0
  132. package/examples/extensions/user-shell.ts +1 -1
  133. package/examples/extensions/web-access.ts +8 -113
  134. package/package.json +26 -22
  135. package/dist/extensions/agent-backend.d.ts +0 -14
  136. package/dist/extensions/agent-backend.js +0 -307
  137. package/dist/types.d.ts +0 -227
  138. /package/dist/{types.js → agent/host-types.js} +0 -0
  139. /package/dist/{extensions → agent}/providers/openai-compatible.js +0 -0
  140. /package/dist/{index.d.ts → cli/index.d.ts} +0 -0
  141. /package/dist/{init.d.ts → cli/init.d.ts} +0 -0
  142. /package/dist/{install.d.ts → cli/install.d.ts} +0 -0
  143. /package/dist/{event-bus.js → core/event-bus.js} +0 -0
  144. /package/dist/{executor.d.ts → utils/executor.d.ts} +0 -0
@@ -0,0 +1,136 @@
1
+ import type { Component } from "@earendil-works/pi-tui";
2
+ import type { ExtensionContext } from "agent-sh/types";
3
+ import {
4
+ AssistantMessage,
5
+ ThinkingBlock,
6
+ ToolResultBody,
7
+ UserMessage,
8
+ } from "./components.js";
9
+ import { entryFor, loadToolDisplayConfig, type ToolResultMode } from "./display-config.js";
10
+
11
+ export interface RenderState {
12
+ state: Record<string, unknown>;
13
+ invalidate: () => void;
14
+ }
15
+
16
+ export interface UserMessageArgs extends RenderState { text: string }
17
+
18
+ export interface AssistantArgs extends RenderState { text: string }
19
+
20
+ export interface ThinkingArgs extends RenderState { text: string; hidden: boolean }
21
+
22
+ export interface ToolCallArgs extends RenderState {
23
+ toolCallId: string;
24
+ name: string;
25
+ title: string;
26
+ kind?: string;
27
+ displayDetail?: string;
28
+ rawInput?: unknown;
29
+ }
30
+
31
+ export interface ToolResultArgs extends RenderState {
32
+ toolCallId: string;
33
+ name: string;
34
+ kind?: string;
35
+ rawInput?: unknown;
36
+ /** Resolved from ashi.display.{name} (or .default) in settings.json. */
37
+ mode: ToolResultMode;
38
+ previewLines: number;
39
+ }
40
+
41
+ /** Mutated by ashi when the tool completes. Renderers may ignore setStatus
42
+ * if they encode status differently (e.g. a sigil in the call line). */
43
+ export interface ToolCallView extends Component {
44
+ setStatus(opts: { exitCode: number | null; elapsedMs: number; summary?: string }): void;
45
+ }
46
+
47
+ /** Mutated by ashi as output streams in and when the tool completes.
48
+ * setDiff is optional behavior — renderers may no-op if they don't show diffs.
49
+ * toggleExpanded flips the view's internal expansion state (Ctrl+O). */
50
+ export interface ToolResultView extends Component {
51
+ appendChunk(chunk: string): void;
52
+ setDiff(lines: string[]): void;
53
+ finalize(opts: { exitCode: number | null; summary?: string }): void;
54
+ toggleExpanded(): void;
55
+ }
56
+
57
+ const CALL_PREFIX = "ashi:render-tool-call:";
58
+ const RESULT_PREFIX = "ashi:render-tool-result:";
59
+
60
+ /** Register the default render-* handlers. Per-tool overrides are advised by
61
+ * name (e.g. `ashi:render-tool-call:bash`); unknown tools fall back to
62
+ * `:default`. */
63
+ export function registerRenderDefaults(ctx: ExtensionContext): void {
64
+ ctx.define("ashi:render-user-message", (args: UserMessageArgs): Component => {
65
+ return new UserMessage(args.text);
66
+ });
67
+
68
+ ctx.define("ashi:render-assistant", (args: AssistantArgs): Component => {
69
+ const msg = new AssistantMessage();
70
+ if (args.text) {
71
+ msg.appendText(args.text);
72
+ msg.finalize();
73
+ }
74
+ return msg;
75
+ });
76
+
77
+ ctx.define("ashi:render-thinking", (args: ThinkingArgs): Component => {
78
+ const tb = new ThinkingBlock();
79
+ if (args.text) {
80
+ tb.appendText(args.text);
81
+ tb.finalize();
82
+ }
83
+ tb.setHidden(args.hidden);
84
+ return tb;
85
+ });
86
+
87
+ ctx.define(`${RESULT_PREFIX}default`, (args: ToolResultArgs): ToolResultView => {
88
+ return new ToolResultBody(args.mode, args.previewLines);
89
+ });
90
+ }
91
+
92
+ export interface ToolHookResolver {
93
+ call: (args: Omit<ToolCallArgs, "state" | "invalidate"> & Partial<RenderState>) => ToolCallView;
94
+ result: (args: Omit<ToolResultArgs, "mode" | "previewLines" | "state" | "invalidate"> & Partial<RenderState>) => ToolResultView;
95
+ modeFor: (name: string) => { mode: ToolResultMode; previewLines: number };
96
+ }
97
+
98
+ /** Resolves :{name} → :default for tool render hooks and looks up each tool's
99
+ * display mode from ashi.display. Cache the registered-handler set; callers
100
+ * can `refresh()` after extensions register new tool-specific renderers. */
101
+ export function createToolHookResolver(
102
+ ctx: ExtensionContext,
103
+ renderState: () => RenderState,
104
+ ): ToolHookResolver & { refresh: () => void } {
105
+ const config = loadToolDisplayConfig();
106
+ let registered = new Set(ctx.list());
107
+
108
+ const pick = (prefix: string, name: string): string => {
109
+ const specific = `${prefix}${name}`;
110
+ return registered.has(specific) ? specific : `${prefix}default`;
111
+ };
112
+
113
+ return {
114
+ refresh(): void {
115
+ registered = new Set(ctx.list());
116
+ },
117
+ modeFor(name: string) {
118
+ const e = entryFor(config, name);
119
+ return { mode: e.result, previewLines: e.previewLines };
120
+ },
121
+ call(args) {
122
+ const handler = pick(CALL_PREFIX, args.name);
123
+ return ctx.call(handler, { ...renderState(), ...args }) as ToolCallView;
124
+ },
125
+ result(args) {
126
+ const { mode, previewLines } = this.modeFor(args.name);
127
+ const handler = pick(RESULT_PREFIX, args.name);
128
+ return ctx.call(handler, {
129
+ ...renderState(),
130
+ ...args,
131
+ mode,
132
+ previewLines,
133
+ }) as ToolResultView;
134
+ },
135
+ };
136
+ }
@@ -0,0 +1,146 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import * as crypto from "node:crypto";
4
+ import { SessionStore, type AgentMessage } from "./session-store.js";
5
+
6
+ export interface SessionInfo {
7
+ id: string;
8
+ filePath: string;
9
+ createdAt: number;
10
+ name?: string;
11
+ preview: string;
12
+ entryCount: number;
13
+ }
14
+
15
+ /** Many sessions per cwd. Each is one .jsonl file under `dir/`. Constructor
16
+ * always opens a fresh session; /resume callers can `openSession(id)` to
17
+ * swap the current store to a past session file. */
18
+ export class MultiSessionStore {
19
+ private dir: string;
20
+ private cwd: string;
21
+ private currentStore: SessionStore;
22
+
23
+ constructor(dir: string, cwd: string) {
24
+ this.dir = dir;
25
+ this.cwd = cwd;
26
+ fs.mkdirSync(dir, { recursive: true });
27
+ this.migrateLegacy();
28
+ this.currentStore = this.createFreshSession();
29
+ }
30
+
31
+ /** One-time import from the previous storage format (sessions stored as
32
+ * directories with tree.jsonl + snapshots/). Each old session is replayed
33
+ * from its most recent snapshot into a new flat `.jsonl` file, then the
34
+ * source directory is renamed `.migrated-<id>/` so the import is idempotent. */
35
+ private migrateLegacy(): void {
36
+ let names: string[];
37
+ try { names = fs.readdirSync(this.dir); } catch { return; }
38
+ for (const name of names) {
39
+ if (name.startsWith(".migrated-")) continue;
40
+ const full = path.join(this.dir, name);
41
+ let stat: fs.Stats;
42
+ try { stat = fs.statSync(full); } catch { continue; }
43
+ if (!stat.isDirectory()) continue;
44
+ const snapshotsDir = path.join(full, "snapshots");
45
+ const leafFile = path.join(full, "active-leaf");
46
+ let leaf: string;
47
+ try { leaf = fs.readFileSync(leafFile, "utf-8").trim(); } catch { continue; }
48
+ let messages: AgentMessage[];
49
+ try {
50
+ const raw = fs.readFileSync(path.join(snapshotsDir, `${leaf}.json`), "utf-8");
51
+ const parsed = JSON.parse(raw);
52
+ if (!Array.isArray(parsed)) continue;
53
+ messages = parsed as AgentMessage[];
54
+ } catch { continue; }
55
+ let createdAt = 0;
56
+ let displayName: string | undefined;
57
+ try {
58
+ const m = JSON.parse(fs.readFileSync(path.join(full, "meta.json"), "utf-8"));
59
+ if (typeof m?.createdAt === "number") createdAt = m.createdAt;
60
+ if (typeof m?.name === "string") displayName = m.name;
61
+ } catch { /* no meta */ }
62
+ const newFile = path.join(this.dir, `${name}.jsonl`);
63
+ try {
64
+ writeImportedSession(newFile, name, this.cwd, messages, createdAt, displayName);
65
+ fs.renameSync(full, path.join(this.dir, `.migrated-${name}`));
66
+ } catch { /* leave the original directory alone if anything failed */ }
67
+ }
68
+ }
69
+
70
+ current(): SessionStore { return this.currentStore; }
71
+
72
+ newSession(): SessionStore {
73
+ this.currentStore = this.createFreshSession();
74
+ return this.currentStore;
75
+ }
76
+
77
+ openSession(id: string): SessionStore {
78
+ const filePath = this.sessionFile(id);
79
+ if (!fs.existsSync(filePath)) throw new Error(`session not found: ${id}`);
80
+ this.currentStore = new SessionStore(filePath);
81
+ return this.currentStore;
82
+ }
83
+
84
+ listSessions(): SessionInfo[] {
85
+ let names: string[];
86
+ try { names = fs.readdirSync(this.dir); } catch { return []; }
87
+ const result: SessionInfo[] = [];
88
+ for (const name of names) {
89
+ if (!name.endsWith(".jsonl")) continue;
90
+ const id = name.slice(0, -".jsonl".length);
91
+ const filePath = path.join(this.dir, name);
92
+ try {
93
+ const store = new SessionStore(filePath);
94
+ const meta = store.getMeta();
95
+ result.push({
96
+ id,
97
+ filePath,
98
+ createdAt: meta.createdAt,
99
+ name: meta.name,
100
+ preview: store.getPreview(),
101
+ entryCount: store.getAllEntries().length,
102
+ });
103
+ } catch { /* skip unreadable */ }
104
+ }
105
+ result.sort((a, b) => b.createdAt - a.createdAt);
106
+ return result;
107
+ }
108
+
109
+ private createFreshSession(): SessionStore {
110
+ const id = newSessionFileId();
111
+ const filePath = this.sessionFile(id);
112
+ return new SessionStore(filePath, { create: { cwd: this.cwd, sessionId: id } });
113
+ }
114
+
115
+ private sessionFile(id: string): string {
116
+ return path.join(this.dir, `${id}.jsonl`);
117
+ }
118
+ }
119
+
120
+ function newSessionFileId(): string {
121
+ const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
122
+ const suffix = crypto.randomBytes(3).toString("hex");
123
+ return `${ts}_${suffix}`;
124
+ }
125
+
126
+ function writeImportedSession(
127
+ newFile: string,
128
+ id: string,
129
+ cwd: string,
130
+ messages: AgentMessage[],
131
+ createdAt: number,
132
+ name?: string,
133
+ ): void {
134
+ const ts = createdAt || Date.now();
135
+ const header = { type: "session", id, parentId: null, timestamp: ts, cwd, version: 1 };
136
+ const lines: string[] = [JSON.stringify(header)];
137
+ let parent: string = id;
138
+ for (const m of messages) {
139
+ const entryId = crypto.randomBytes(4).toString("hex");
140
+ lines.push(JSON.stringify({ type: "message", id: entryId, parentId: parent, timestamp: ts, message: m }));
141
+ parent = entryId;
142
+ }
143
+ fs.writeFileSync(newFile, lines.join("\n") + "\n");
144
+ fs.writeFileSync(newFile + ".leaf", parent);
145
+ fs.writeFileSync(newFile + ".meta", JSON.stringify({ createdAt: ts, ...(name ? { name } : {}) }));
146
+ }
@@ -0,0 +1,76 @@
1
+ import type { ExtensionContext } from "agent-sh/types";
2
+ import type { MultiSessionStore, SessionInfo } from "./multi-session-store.js";
3
+ import type { Capture } from "./capture.js";
4
+ import { applyBranchMessages } from "./commands.js";
5
+
6
+ export interface SessionCommandsDeps {
7
+ openSessionPicker: () => Promise<void>;
8
+ rebuildChat: () => Promise<void>;
9
+ }
10
+
11
+ export function registerSessionCommands(
12
+ ctx: ExtensionContext,
13
+ getStore: () => MultiSessionStore,
14
+ capture: Capture,
15
+ deps: SessionCommandsDeps,
16
+ ): void {
17
+ const { bus } = ctx;
18
+
19
+ ctx.registerCommand("resume", "Browse and resume a past session in this cwd", async () => {
20
+ await deps.openSessionPicker();
21
+ });
22
+
23
+ ctx.registerCommand("new", "Start a fresh session (discards in-memory context)", async () => {
24
+ const s = getStore().newSession();
25
+ ctx.call("conversation:reset-for-session", 1);
26
+ ctx.call("conversation:replace-messages", []);
27
+ capture.resetTo([]);
28
+ await deps.rebuildChat();
29
+ bus.emit("ui:info", { message: `new session: ${s.id}` });
30
+ });
31
+
32
+ ctx.registerCommand("name", "Set the current session display name: /name <text>", async (args) => {
33
+ const name = args.trim();
34
+ if (!name) {
35
+ bus.emit("ui:error", { message: "name: expected a name" });
36
+ return;
37
+ }
38
+ getStore().current().setName(name);
39
+ bus.emit("ui:info", { message: `session named: ${name}` });
40
+ });
41
+
42
+ ctx.registerCommand("sessions", "List past sessions in this cwd (text dump)", async () => {
43
+ const list = getStore().listSessions();
44
+ if (list.length === 0) {
45
+ bus.emit("ui:info", { message: "sessions: none" });
46
+ return;
47
+ }
48
+ const currentId = getStore().current().id;
49
+ const lines = list.map((s) => formatSessionRow(s, s.id === currentId));
50
+ bus.emit("ui:info", { message: `sessions (${list.length}):\n${lines.join("\n")}` });
51
+ });
52
+ }
53
+
54
+ function formatLocal(ts: number): string {
55
+ const d = new Date(ts);
56
+ const pad = (n: number): string => n.toString().padStart(2, "0");
57
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
58
+ }
59
+
60
+ export function formatSessionRow(s: SessionInfo, isCurrent: boolean): string {
61
+ const marker = isCurrent ? "●" : " ";
62
+ const when = s.createdAt ? formatLocal(s.createdAt) : "?";
63
+ const label = s.name ?? s.preview;
64
+ return `${marker} ${when} ${label} (${s.entryCount})`;
65
+ }
66
+
67
+ export function resumeSession(
68
+ ctx: ExtensionContext,
69
+ getStore: () => MultiSessionStore,
70
+ capture: Capture,
71
+ id: string,
72
+ ): void {
73
+ getStore().openSession(id);
74
+ ctx.call("conversation:reset-for-session", 1);
75
+ applyBranchMessages(ctx, getStore, capture);
76
+ }
@@ -0,0 +1,264 @@
1
+ import * as fs from "node:fs";
2
+ import * as fsp from "node:fs/promises";
3
+ import * as path from "node:path";
4
+ import * as crypto from "node:crypto";
5
+
6
+ export interface ToolCall {
7
+ id?: string;
8
+ function?: { name: string; arguments?: string };
9
+ }
10
+
11
+ export interface AgentMessage {
12
+ role: "system" | "user" | "assistant" | "tool";
13
+ content?: unknown;
14
+ tool_calls?: ToolCall[];
15
+ tool_call_id?: string;
16
+ name?: string;
17
+ }
18
+
19
+ export interface SessionHeaderEntry {
20
+ type: "session";
21
+ id: string;
22
+ parentId: null;
23
+ timestamp: number;
24
+ cwd: string;
25
+ version: 1;
26
+ }
27
+
28
+ export interface MessageEntry {
29
+ type: "message";
30
+ id: string;
31
+ parentId: string;
32
+ timestamp: number;
33
+ message: AgentMessage;
34
+ }
35
+
36
+ export interface CompactionEntry {
37
+ type: "compaction";
38
+ id: string;
39
+ parentId: string;
40
+ timestamp: number;
41
+ summary: string;
42
+ firstKeptId: string;
43
+ tokensBefore: number;
44
+ }
45
+
46
+ export type SessionEntry = SessionHeaderEntry | MessageEntry | CompactionEntry;
47
+
48
+ export interface SessionMeta {
49
+ name?: string;
50
+ createdAt: number;
51
+ }
52
+
53
+ export function newEntryId(): string {
54
+ return crypto.randomBytes(4).toString("hex");
55
+ }
56
+
57
+ /** One session = one JSONL file (entries) + sidecar files for leaf & meta.
58
+ * Tree is implicit via parentId pointers; entries kept in memory after load. */
59
+ export class SessionStore {
60
+ private entriesPath: string;
61
+ private leafPath: string;
62
+ private metaPath: string;
63
+ private entries = new Map<string, SessionEntry>();
64
+ private rootId = "";
65
+ private activeLeaf = "";
66
+ private meta: SessionMeta;
67
+ private pendingHeader: SessionHeaderEntry | null = null;
68
+ readonly id: string;
69
+
70
+ constructor(filePath: string, opts?: { create?: { cwd: string; sessionId: string } }) {
71
+ this.entriesPath = filePath;
72
+ this.leafPath = filePath + ".leaf";
73
+ this.metaPath = filePath + ".meta";
74
+ this.meta = { createdAt: 0 };
75
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
76
+
77
+ if (opts?.create) {
78
+ this.id = opts.create.sessionId;
79
+ const header: SessionHeaderEntry = {
80
+ type: "session",
81
+ id: opts.create.sessionId,
82
+ parentId: null,
83
+ timestamp: Date.now(),
84
+ cwd: opts.create.cwd,
85
+ version: 1,
86
+ };
87
+ this.entries.set(header.id, header);
88
+ this.rootId = header.id;
89
+ this.activeLeaf = header.id;
90
+ this.meta = { createdAt: header.timestamp };
91
+ this.pendingHeader = header;
92
+ } else {
93
+ this.id = "";
94
+ this.load();
95
+ if (!this.rootId) throw new Error(`session file lacks a session header: ${filePath}`);
96
+ this.id = this.rootId;
97
+ }
98
+ }
99
+
100
+ private flushHeader(): void {
101
+ if (!this.pendingHeader) return;
102
+ const headerLine = JSON.stringify(this.pendingHeader) + "\n";
103
+ this.pendingHeader = null;
104
+ fs.writeFileSync(this.entriesPath, headerLine);
105
+ this.persistMeta();
106
+ this.persistLeaf();
107
+ }
108
+
109
+ getActiveLeaf(): string { return this.activeLeaf; }
110
+ setActiveLeaf(id: string): void {
111
+ if (!this.entries.has(id)) throw new Error(`unknown entry: ${id}`);
112
+ this.activeLeaf = id;
113
+ this.persistLeaf();
114
+ }
115
+ getRootId(): string { return this.rootId; }
116
+ getEntry(id: string): SessionEntry | undefined { return this.entries.get(id); }
117
+ getAllEntries(): SessionEntry[] {
118
+ return [...this.entries.values()];
119
+ }
120
+ getMeta(): SessionMeta { return { ...this.meta }; }
121
+ setName(name: string): void {
122
+ this.meta.name = name;
123
+ this.persistMeta();
124
+ }
125
+
126
+ /** Append messages as a chain of MessageEntry, each parented at the
127
+ * previously appended id (starting from current leaf). Returns the new
128
+ * entry ids in order. */
129
+ async appendMessages(messages: AgentMessage[]): Promise<string[]> {
130
+ if (messages.length === 0) return [];
131
+ this.flushHeader();
132
+ let parent = this.activeLeaf;
133
+ const lines: string[] = [];
134
+ const newIds: string[] = [];
135
+ for (const m of messages) {
136
+ const e: MessageEntry = {
137
+ type: "message",
138
+ id: newEntryId(),
139
+ parentId: parent,
140
+ timestamp: Date.now(),
141
+ message: m,
142
+ };
143
+ this.entries.set(e.id, e);
144
+ lines.push(JSON.stringify(e));
145
+ newIds.push(e.id);
146
+ parent = e.id;
147
+ }
148
+ this.activeLeaf = parent;
149
+ await fsp.appendFile(this.entriesPath, lines.join("\n") + "\n");
150
+ this.persistLeaf();
151
+ return newIds;
152
+ }
153
+
154
+ async appendCompaction(summary: string, firstKeptId: string, tokensBefore: number): Promise<string> {
155
+ if (!this.entries.has(firstKeptId)) throw new Error(`firstKeptId unknown: ${firstKeptId}`);
156
+ this.flushHeader();
157
+ const e: CompactionEntry = {
158
+ type: "compaction",
159
+ id: newEntryId(),
160
+ parentId: this.activeLeaf,
161
+ timestamp: Date.now(),
162
+ summary,
163
+ firstKeptId,
164
+ tokensBefore,
165
+ };
166
+ this.entries.set(e.id, e);
167
+ this.activeLeaf = e.id;
168
+ await fsp.appendFile(this.entriesPath, JSON.stringify(e) + "\n");
169
+ this.persistLeaf();
170
+ return e.id;
171
+ }
172
+
173
+ /** Walk parent pointers from a leaf back to the root. Returns oldest-first. */
174
+ getBranch(leafId: string = this.activeLeaf): SessionEntry[] {
175
+ const out: SessionEntry[] = [];
176
+ const seen = new Set<string>();
177
+ let cur: string | null = leafId;
178
+ while (cur && !seen.has(cur)) {
179
+ seen.add(cur);
180
+ const e = this.entries.get(cur);
181
+ if (!e) break;
182
+ out.push(e);
183
+ cur = e.parentId;
184
+ }
185
+ return out.reverse();
186
+ }
187
+
188
+ /** Reconstruct the live message array for the active leaf, honoring the
189
+ * latest compaction on the branch (summary + kept tail). Mirrors pi's
190
+ * buildSessionContext. */
191
+ buildMessages(leafId: string = this.activeLeaf): AgentMessage[] {
192
+ const branch = this.getBranch(leafId);
193
+ let compactionIdx = -1;
194
+ for (let i = branch.length - 1; i >= 0; i--) {
195
+ if (branch[i]!.type === "compaction") { compactionIdx = i; break; }
196
+ }
197
+ if (compactionIdx < 0) {
198
+ return branch
199
+ .filter((e): e is MessageEntry => e.type === "message")
200
+ .map((e) => e.message);
201
+ }
202
+ const c = branch[compactionIdx] as CompactionEntry;
203
+ const firstKeptIdx = branch.findIndex((e) => e.id === c.firstKeptId);
204
+ const keepFrom = firstKeptIdx >= 0 ? firstKeptIdx : 0;
205
+ const out: AgentMessage[] = [{
206
+ role: "user",
207
+ content: `[Compacted conversation summary]\n${c.summary}`,
208
+ }];
209
+ for (let i = keepFrom; i < branch.length; i++) {
210
+ const e = branch[i]!;
211
+ if (e.type === "message") out.push(e.message);
212
+ }
213
+ return out;
214
+ }
215
+
216
+ /** A short, human-friendly preview for picker rows. Uses the first user
217
+ * message's text when available, else the session id. */
218
+ getPreview(): string {
219
+ for (const e of this.entries.values()) {
220
+ if (e.type === "message" && e.message.role === "user") {
221
+ const txt = typeof e.message.content === "string" ? e.message.content : "";
222
+ if (txt) return txt.slice(0, 80);
223
+ }
224
+ }
225
+ return "(empty)";
226
+ }
227
+
228
+ private load(): void {
229
+ try {
230
+ this.meta = JSON.parse(fs.readFileSync(this.metaPath, "utf-8")) as SessionMeta;
231
+ } catch { this.meta = { createdAt: 0 }; }
232
+ let raw: string;
233
+ try { raw = fs.readFileSync(this.entriesPath, "utf-8"); }
234
+ catch { return; }
235
+ for (const line of raw.split("\n")) {
236
+ if (!line) continue;
237
+ try {
238
+ const e = JSON.parse(line) as SessionEntry;
239
+ if (!e.id) continue;
240
+ this.entries.set(e.id, e);
241
+ if (e.type === "session") this.rootId = e.id;
242
+ } catch { /* skip malformed */ }
243
+ }
244
+ try {
245
+ this.activeLeaf = fs.readFileSync(this.leafPath, "utf-8").trim();
246
+ if (!this.entries.has(this.activeLeaf)) this.activeLeaf = this.rootId;
247
+ } catch { this.activeLeaf = this.lastEntryId(); }
248
+ }
249
+
250
+ private lastEntryId(): string {
251
+ let lastId = this.rootId;
252
+ for (const e of this.entries.values()) lastId = e.id;
253
+ return lastId;
254
+ }
255
+
256
+ private persistLeaf(): void {
257
+ if (this.pendingHeader) return;
258
+ fs.writeFileSync(this.leafPath, this.activeLeaf);
259
+ }
260
+ private persistMeta(): void {
261
+ if (this.pendingHeader) return;
262
+ fs.writeFileSync(this.metaPath, JSON.stringify(this.meta));
263
+ }
264
+ }
@@ -0,0 +1,66 @@
1
+ import { Container, Text } from "@earendil-works/pi-tui";
2
+ import { theme } from "./theme.js";
3
+
4
+ interface StatusFields {
5
+ model?: string;
6
+ provider?: string;
7
+ contextWindow?: number;
8
+ cwd?: string;
9
+ branch?: string;
10
+ leaf?: number;
11
+ tokens?: number;
12
+ compactions?: number;
13
+ thinking?: string;
14
+ }
15
+
16
+ export class StatusFooter extends Container {
17
+ private text: Text;
18
+ private fields: StatusFields = {};
19
+
20
+ constructor() {
21
+ super();
22
+ this.text = new Text("", 1, 0);
23
+ this.addChild(this.text);
24
+ }
25
+
26
+ update(patch: Partial<StatusFields>): void {
27
+ this.fields = { ...this.fields, ...patch };
28
+ this.repaint();
29
+ }
30
+
31
+ private repaint(): void {
32
+ const { model, provider, contextWindow, cwd, branch, leaf, tokens, compactions, thinking } = this.fields;
33
+ const sep = theme.fg("dim", " | ");
34
+ const parts: string[] = [];
35
+ if (model) {
36
+ const tail = provider ? theme.fg("muted", `@${provider}`) : "";
37
+ const think = thinking ? theme.fg("muted", ` [${thinking}]`) : "";
38
+ parts.push(`${theme.fg("accent", model)}${tail ? " " + tail : ""}${think}`);
39
+ } else if (provider) {
40
+ parts.push(theme.fg("muted", `@${provider}`));
41
+ }
42
+ if (cwd) parts.push(theme.fg("muted", shortenCwd(cwd)));
43
+ if (branch) parts.push(theme.fg("muted", `⎇ ${branch}`));
44
+ if (leaf != null && leaf > 0) parts.push(theme.fg("muted", `#${leaf}`));
45
+ if (tokens != null) {
46
+ const tokStr = contextWindow ? `${fmtTokens(tokens)}/${fmtTokens(contextWindow)}` : fmtTokens(tokens);
47
+ const pct = contextWindow ? ` ${theme.fg("dim", `${Math.round((tokens / contextWindow) * 100)}%`)}` : "";
48
+ parts.push(`${theme.fg("muted", tokStr)}${pct}`);
49
+ }
50
+ if (compactions && compactions > 0) parts.push(theme.fg("muted", `⊟ ${compactions}`));
51
+ this.text.setText(parts.length === 0 ? "" : parts.join(sep));
52
+ }
53
+ }
54
+
55
+ function shortenCwd(cwd: string): string {
56
+ const home = process.env.HOME;
57
+ if (home && cwd.startsWith(`${home}/`)) return `~/${cwd.slice(home.length + 1)}`;
58
+ if (home && cwd === home) return "~";
59
+ return cwd;
60
+ }
61
+
62
+ function fmtTokens(n: number): string {
63
+ if (n < 1000) return String(n);
64
+ if (n < 100_000) return `${(n / 1000).toFixed(1)}k`;
65
+ return `${Math.round(n / 1000)}k`;
66
+ }