agent-sh 0.12.27 → 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 +42 -98
  4. package/dist/agent/conversation-state.d.ts +9 -0
  5. package/dist/agent/conversation-state.js +16 -0
  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} +31 -2
  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 +1 -1
  89. package/dist/utils/floating-panel.js +9 -4
  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 +1 -1
  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 +7 -5
  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 +1 -1
  130. package/examples/extensions/tmux-pane.ts +6 -4
  131. package/examples/extensions/tunnel-vision.ts +5 -5
  132. package/examples/extensions/user-shell.ts +1 -1
  133. package/examples/extensions/web-access.ts +5 -5
  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,250 @@
1
+ # ashi
2
+
3
+ `ash` (agent-sh's built-in agent) running inside pi's TUI substrate, with no shell underneath.
4
+
5
+ A test of agent-sh's claim that the kernel is a frontend-agnostic communication layer: `ashi`
6
+ uses `createCore()` from agent-sh, skips `activateShell()`, disables the shell-coupled built-ins
7
+ (`shell-context`, `tui-renderer`, `file-autocomplete`), and mounts `@earendil-works/pi-tui` as
8
+ the only frontend. Backend, tools, slash commands, providers, and skills come along unchanged.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ agent-sh install ashi
14
+ export PATH="$HOME/.agent-sh/bin:$PATH"
15
+ ashi
16
+ ```
17
+
18
+ `agent-sh install` copies this directory into `~/.agent-sh/extensions/ashi/`, runs
19
+ `npm install` and `npm run build` there, and symlinks the built bin into `~/.agent-sh/bin/`.
20
+
21
+ ## Configure
22
+
23
+ Reads `~/.agent-sh/settings.json` for providers and defaults, same as `agent-sh` itself. The
24
+ quickest path is exporting `OPENROUTER_API_KEY` or `OPENAI_API_KEY` and running `ashi`.
25
+
26
+ See the agent-sh [Usage Guide](https://github.com/guanyilun/agent-sh/blob/main/docs/usage.md)
27
+ for the full `settings.json` schema, provider profiles, and model selection details.
28
+
29
+ CLI flags mirror `agent-sh`:
30
+
31
+ ```
32
+ --provider <name> Provider profile from ~/.agent-sh/settings.json
33
+ --model <id> Override model
34
+ --api-key <key> Direct API key
35
+ --base-url <url> OpenAI-compatible base URL
36
+ --backend <name> Agent backend (default: ash)
37
+ -e, --extensions Extra extensions to load (comma-separated)
38
+ ```
39
+
40
+ Extensions loaded via `-e` follow the standard agent-sh extension contract — see
41
+ [Extensions](https://github.com/guanyilun/agent-sh/blob/main/docs/extensions.md) for the
42
+ `ExtensionContext` API, event bus, content transforms, and custom backends.
43
+
44
+ ## Keybindings
45
+
46
+ Match pi-coding-agent's convention:
47
+
48
+ ```
49
+ Esc Cancel active turn
50
+ Ctrl+C Clear editor
51
+ Ctrl+D Quit (when editor is empty)
52
+ Ctrl+T Toggle thinking-block visibility
53
+ Ctrl+O Expand/collapse the most recent tool result
54
+ ```
55
+
56
+ ## Sessions
57
+
58
+ ashi mirrors pi's session model: many sessions per cwd, fresh by default.
59
+
60
+ ```
61
+ /resume Browse past sessions in this cwd (interactive picker)
62
+ /new Start a fresh session (discards in-memory context)
63
+ /name <text> Set a display name for the current session
64
+ /sessions Text dump of all sessions in this cwd
65
+ ```
66
+
67
+ Each session is its own tree (one JSONL file per session). Every entry has an `id` and
68
+ `parentId`; sibling branches stay on disk; you can rewind and branch within a session.
69
+
70
+ ```
71
+ /fork Interactive in-session tree picker
72
+ /fork <id-prefix> Direct rewind to a specific entry
73
+ /branch Text dump of the active branch (root → leaf)
74
+ ```
75
+
76
+ Storage: `~/.agent-sh/extensions/ashi/history/<cwd-slug>/sessions/<id>.jsonl`. Each line
77
+ is a `SessionEntry`:
78
+
79
+ ```typescript
80
+ type SessionEntry =
81
+ | { type: "session"; id; parentId: null; cwd; timestamp; version }
82
+ | { type: "message"; id; parentId; timestamp; message: AgentMessage }
83
+ | { type: "compaction"; id; parentId; timestamp; summary; firstKeptId; tokensBefore };
84
+ ```
85
+
86
+ Raw `AgentMessage` objects are stored verbatim (full tool call arguments, tool results,
87
+ etc.) so `/resume` and `/fork` faithfully reconstruct the original conversation — same
88
+ shape as pi's session format.
89
+
90
+ The kernel side adds three small handlers:
91
+ optional `parentSeq`/`getBranch`/`getTree`/`setLeaf` on `NuclearEntry`/`HistoryAdapter`
92
+ (useful for tree-aware HistoryAdapters in general — not used by this extension);
93
+ `conversation:allocate-seq` and `conversation:reset-for-session` so multi-session adapters
94
+ can swap context without nuclear-state bleed-through.
95
+
96
+ ashi itself bypasses agent-sh's `NuclearEntry` pipeline entirely by installing a
97
+ `NoopHistory` adapter — raw messages are captured directly via `agent:processing-done`
98
+ and the `conversation:get-messages` handler.
99
+
100
+ ## Compaction
101
+
102
+ ashi replaces agent-sh's default deterministic two-tier-pin compaction with a pi-style
103
+ LLM-driven path:
104
+
105
+ 1. Cut point: walk back from the newest message until ~20K tokens are kept; never cut at
106
+ tool results or in the middle of an assistant→tool call group.
107
+ 2. LLM summarizes the older span into the pi structured format (Goal / Constraints /
108
+ Progress / Decisions / Next Steps / Critical Context).
109
+ 3. The live message array becomes `[summary, ...kept messages]`.
110
+ 4. The summary lands in the session as a `CompactionEntry` carrying `summary`,
111
+ `firstKeptId`, and `tokensBefore` — same shape as pi's compaction. Subsequent
112
+ compactions reference the previous one's summary so chains stay coherent.
113
+
114
+ Triggered automatically when prompt tokens cross agent-sh's threshold, or manually with
115
+ `/compact`. If the LLM call fails or the conversation is too short, falls through to the
116
+ default eviction.
117
+
118
+ The cut-point walker, prompt template, serialization, and LLM call all live in this
119
+ extension. The kernel side is just the advisable `conversation:compact` seam.
120
+
121
+ ## What's intentionally missing
122
+
123
+ This is a spike, not a clone of pi's full UI. The MVP renders:
124
+
125
+ - User submissions, streaming assistant Markdown
126
+ - Tool invocations with start/complete state
127
+ - Slash commands with autocomplete (`/help`, `/model`, `/backend`, `/resume`, `/new`, `/fork`, …)
128
+ - Multi-session tree history with `/resume` and `/fork` pickers
129
+ - LLM compaction with summaries that survive across `/resume`
130
+ - Loader, errors, info messages
131
+ - Inline images via the `image` ContentBlock and the `render:image` handler — the
132
+ bundled `latex-images` extension works against ashi without modification
133
+ (terminal must support iTerm2 or Kitty graphics)
134
+
135
+ Out of scope for v0: branch summaries on `/fork` navigation (pi has this), `/clone`
136
+ (duplicate active branch into a new session), permission dialogs, diff renderer, file-path
137
+ autocomplete, session search/rename/delete inside the `/resume` picker, theme selector.
138
+ Each can be added by writing a pi-tui Component and subscribing to the corresponding
139
+ bus event.
140
+
141
+ ## Extension surface
142
+
143
+ Other extensions can override how chat entries render without forking ashi.
144
+ Hooks are exposed via `ctx.define` (defaults) + `ctx.advise` (override).
145
+
146
+ ### Chat hooks
147
+
148
+ | Hook | Args | Returns |
149
+ |---|---|---|
150
+ | `ashi:render-user-message` | `{ text, state, invalidate }` | `Component` |
151
+ | `ashi:render-assistant` | `{ text, state, invalidate }` | `Component` |
152
+ | `ashi:render-thinking` | `{ text, hidden, state, invalidate }` | `Component` |
153
+
154
+ ### Tool hooks (per-tool)
155
+
156
+ Tool rendering is split into a call line (the input header) and a result body
157
+ (streaming output + final state). Each side is dispatched by tool name with a
158
+ `:default` fallback:
159
+
160
+ | Hook | Args | Returns |
161
+ |---|---|---|
162
+ | `ashi:render-tool-call:{name}` | `{ toolCallId, name, title, kind, displayDetail, rawInput, state, invalidate }` | `ToolCallView` |
163
+ | `ashi:render-tool-call:default` | (same) | `ToolCallView` |
164
+ | `ashi:render-tool-result:{name}` | `{ toolCallId, name, kind, rawInput, mode, previewLines, state, invalidate }` | `ToolResultView` |
165
+ | `ashi:render-tool-result:default` | (same) | `ToolResultView` |
166
+
167
+ `state` is a per-call mutable bag; `invalidate()` requests a re-render.
168
+
169
+ - `ToolCallView` extends `Component` with `setStatus({ exitCode, elapsedMs, summary })` — called once on completion.
170
+ - `ToolResultView` extends `Component` with `appendChunk(chunk)`, `setDiff(lines)`, `finalize({ exitCode, summary })`, and `toggleExpanded()` — ashi mutates the result view as output streams in and when the user hits `Ctrl+O`.
171
+
172
+ `mode` and `previewLines` on result args come from `ashi.display.{name}` config
173
+ (see below) so renderers can honor the user's compactness preference without
174
+ re-implementing the resolution logic.
175
+
176
+ Example: override how `bash` calls render.
177
+
178
+ ```ts
179
+ export default function activate(ctx) {
180
+ ctx.advise("ashi:render-tool-call:bash", (next, args) => {
181
+ return new MyFancyBashLine(args); // must implement ToolCallView
182
+ });
183
+ }
184
+ ```
185
+
186
+ For non-render concerns (commands, settings, tools, providers) use the standard
187
+ agent-sh extension API.
188
+
189
+ ## Display configuration
190
+
191
+ Per-tool compactness lives under `ashi.display` in `~/.agent-sh/settings.json`:
192
+
193
+ ```json
194
+ {
195
+ "ashi": {
196
+ "display": {
197
+ "default": { "result": "preview", "previewLines": 8 },
198
+ "read": { "result": "hidden" },
199
+ "ls": { "result": "hidden" },
200
+ "grep": { "result": "summary" },
201
+ "bash": { "result": "preview", "previewLines": 12 },
202
+ "edit": { "result": "preview" },
203
+ "write": { "result": "preview" }
204
+ }
205
+ }
206
+ }
207
+ ```
208
+
209
+ `result` modes:
210
+
211
+ - `"hidden"` — call line only while streaming; line count (`↳ 42 lines`) after completion.
212
+ - `"summary"` — 2-line tail while streaming; line count after completion.
213
+ - `"preview"` — last `previewLines` lines of output (default 8), with a `... (N more lines)` hint when content overflows.
214
+
215
+ For `edit_file` / `write_file`, the diff frame is treated as the output and
216
+ follows the same gating: shown for `preview`, hidden for `hidden`/`summary`
217
+ (the call line already carries `+12 -3` stats). The line-count hint is
218
+ suppressed for diff-producing tools so edits stay quiet.
219
+
220
+ Hit `Ctrl+O` to expand the most recent tool result inline — shows the full
221
+ output buffer and the full diff regardless of mode. Press again to collapse.
222
+
223
+ Each tool inherits from `default` and is overridden by its own block. Unknown
224
+ tool names fall through to `default`. Built-in defaults aim for compactness
225
+ (`read`/`ls` hidden, `grep` summarized) — override under `default` to widen
226
+ everything, or under a specific tool to tune one.
227
+
228
+ ## Development
229
+
230
+ `@guanyilun/ashi` depends on the published `agent-sh` package. To iterate against
231
+ the parent checkout instead, use `npm link`:
232
+
233
+ ```bash
234
+ # one-time: register the local agent-sh checkout
235
+ cd /path/to/agent-sh
236
+ npm run build
237
+ npm link
238
+
239
+ # in ashi, point its agent-sh dependency at the linked checkout
240
+ cd examples/extensions/ashi
241
+ npm install
242
+ npm link agent-sh
243
+
244
+ npm run dev # tsx-driven, no compile step
245
+ # or: npm run build && node dist/cli.js
246
+ ```
247
+
248
+ Rebuild agent-sh (`npm run build` at the repo root) whenever you change the
249
+ kernel — the link picks up `dist/` directly. To go back to the published
250
+ version, run `npm unlink agent-sh && npm install` inside `examples/extensions/ashi`.
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@guanyilun/ashi",
3
+ "version": "0.1.0",
4
+ "description": "Ash with a pi-tui frontend — agent-sh's kernel without the shell, rendered through pi's TUI library",
5
+ "type": "module",
6
+ "main": "dist/cli.js",
7
+ "bin": {
8
+ "ashi": "dist/cli.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "README.md"
13
+ ],
14
+ "scripts": {
15
+ "dev": "tsx src/cli.ts",
16
+ "clean": "rm -rf dist",
17
+ "build": "npm run clean && tsc",
18
+ "start": "node dist/cli.js",
19
+ "prepublishOnly": "npm run build"
20
+ },
21
+ "keywords": [
22
+ "agent-sh",
23
+ "ashi",
24
+ "ash",
25
+ "pi-tui",
26
+ "tui",
27
+ "agent",
28
+ "ai",
29
+ "llm",
30
+ "cli"
31
+ ],
32
+ "author": "Yilun Guan",
33
+ "license": "MIT",
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/guanyilun/agent-sh.git",
37
+ "directory": "examples/extensions/ashi"
38
+ },
39
+ "homepage": "https://github.com/guanyilun/agent-sh/tree/main/examples/extensions/ashi",
40
+ "bugs": {
41
+ "url": "https://github.com/guanyilun/agent-sh/issues"
42
+ },
43
+ "engines": {
44
+ "node": ">=18"
45
+ },
46
+ "publishConfig": {
47
+ "access": "public"
48
+ },
49
+ "dependencies": {
50
+ "@earendil-works/pi-tui": "^0.74.0",
51
+ "agent-sh": "^0.12.27",
52
+ "chalk": "^5.5.0",
53
+ "cli-highlight": "^2.1.11"
54
+ },
55
+ "devDependencies": {
56
+ "@types/node": "^22.0.0",
57
+ "tsx": "^4.20.0",
58
+ "typescript": "^5.7.0"
59
+ }
60
+ }
@@ -0,0 +1,91 @@
1
+ import type {
2
+ AutocompleteItem,
3
+ AutocompleteProvider,
4
+ AutocompleteSuggestions,
5
+ } from "@earendil-works/pi-tui";
6
+ import type { EventBus } from "agent-sh/event-bus";
7
+
8
+ /** Adapt pi-tui's AutocompleteProvider to agent-sh's autocomplete:request pipe.
9
+ * Reads the line up to the cursor, asks the bus for suggestions, returns them. */
10
+ export class BusAutocompleteProvider implements AutocompleteProvider {
11
+ constructor(private bus: EventBus) {}
12
+
13
+ async getSuggestions(
14
+ lines: string[],
15
+ cursorLine: number,
16
+ cursorCol: number,
17
+ ): Promise<AutocompleteSuggestions | null> {
18
+ if (cursorLine !== 0) return null;
19
+ const line = lines[0] ?? "";
20
+ const before = line.slice(0, cursorCol);
21
+
22
+ const atSpan = findAtTrigger(before);
23
+ if (atSpan) {
24
+ const result = this.bus.emitPipe("autocomplete:request", {
25
+ buffer: atSpan.text, command: null, commandArgs: null, items: [],
26
+ });
27
+ if (result.items.length === 0) return null;
28
+ const items: AutocompleteItem[] = result.items.map((it) => ({
29
+ value: "@" + it.name,
30
+ label: "@" + it.name,
31
+ description: it.description,
32
+ }));
33
+ return { items, prefix: atSpan.text };
34
+ }
35
+
36
+ if (!before.startsWith("/")) return null;
37
+
38
+ const sp = before.indexOf(" ");
39
+ const command = sp === -1 ? null : before.slice(0, sp);
40
+ const commandArgs = sp === -1 ? null : before.slice(sp + 1);
41
+
42
+ const result = this.bus.emitPipe("autocomplete:request", {
43
+ buffer: before,
44
+ command,
45
+ commandArgs,
46
+ items: [],
47
+ });
48
+ if (result.items.length === 0) return null;
49
+
50
+ const items: AutocompleteItem[] = result.items.map((it) => ({
51
+ value: it.name,
52
+ label: it.name,
53
+ description: it.description,
54
+ }));
55
+ return { items, prefix: before };
56
+ }
57
+
58
+ applyCompletion(
59
+ lines: string[],
60
+ cursorLine: number,
61
+ cursorCol: number,
62
+ item: AutocompleteItem,
63
+ prefix: string,
64
+ ): { lines: string[]; cursorLine: number; cursorCol: number } {
65
+ const line = lines[cursorLine] ?? "";
66
+ // Replace the prefix span (the leading slash-word + args we sent) with
67
+ // the completion value. Anything after the cursor is preserved.
68
+ const head = line.slice(0, cursorCol - prefix.length);
69
+ const tail = line.slice(cursorCol);
70
+ const newLine = `${head}${item.value}${tail}`;
71
+ const out = lines.slice();
72
+ out[cursorLine] = newLine;
73
+ return {
74
+ lines: out,
75
+ cursorLine,
76
+ cursorCol: (head + item.value).length,
77
+ };
78
+ }
79
+ }
80
+
81
+ /** Locate an active `@` file-trigger in the text preceding the cursor.
82
+ * Matches when `@` is at start or after whitespace and the chars from
83
+ * `@` to cursor are path-friendly (letters, digits, `.`, `/`, `_`, `-`). */
84
+ function findAtTrigger(before: string): { text: string } | null {
85
+ const at = before.lastIndexOf("@");
86
+ if (at < 0) return null;
87
+ if (at > 0 && !/\s/.test(before[at - 1]!)) return null;
88
+ const query = before.slice(at + 1);
89
+ if (!/^[a-zA-Z0-9_./-]*$/.test(query)) return null;
90
+ return { text: before.slice(at) };
91
+ }
@@ -0,0 +1,34 @@
1
+ import type { ExtensionContext } from "agent-sh/types";
2
+ import type { MultiSessionStore } from "./multi-session-store.js";
3
+ import type { AgentMessage } from "./session-store.js";
4
+
5
+ /** Maintains an `(entryId | null)[]` parallel to the live messages array;
6
+ * null slots are synthetics like compaction summaries that have no entry. */
7
+ export interface Capture {
8
+ flush(): Promise<void>;
9
+ getEntryIdAt(messageIndex: number): string | null;
10
+ resetTo(ids: (string | null)[]): void;
11
+ }
12
+
13
+ export function registerCapture(
14
+ ctx: ExtensionContext,
15
+ getStore: () => MultiSessionStore,
16
+ ): Capture {
17
+ let liveEntryIds: (string | null)[] = [];
18
+
19
+ const flush = async (): Promise<void> => {
20
+ const messages = ctx.call("conversation:get-messages") as AgentMessage[] | undefined;
21
+ if (!messages || messages.length <= liveEntryIds.length) return;
22
+ const newMessages = messages.slice(liveEntryIds.length);
23
+ const newIds = await getStore().current().appendMessages(newMessages);
24
+ liveEntryIds = [...liveEntryIds, ...newIds];
25
+ };
26
+
27
+ ctx.bus.on("agent:processing-done", () => { void flush(); });
28
+
29
+ return {
30
+ flush,
31
+ getEntryIdAt: (i) => liveEntryIds[i] ?? null,
32
+ resetTo: (ids) => { liveEntryIds = [...ids]; },
33
+ };
34
+ }
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ashi — agent-sh's ash backend with a pi-tui frontend, no shell.
4
+ *
5
+ * Boots the agent-sh kernel directly, skips the PTY shell and the
6
+ * default streaming tui-renderer, and mounts pi-tui as the sole
7
+ * frontend. Demonstrates that the kernel is frontend-agnostic — same
8
+ * backend, tools, slash commands, providers; different presentation.
9
+ */
10
+ import { createCore, NoopHistory } from "agent-sh/core";
11
+ import { loadBuiltinExtensions } from "agent-sh/extensions";
12
+ import { loadExtensions } from "agent-sh/extension-loader";
13
+ import { activateAgent } from "agent-sh/agent";
14
+ import { getSettings } from "agent-sh/settings";
15
+ import type { AppConfig } from "agent-sh/types";
16
+
17
+ import { mountAshi } from "./frontend.js";
18
+ import { MultiSessionStore } from "./multi-session-store.js";
19
+ import { registerForkCommands } from "./commands.js";
20
+ import { registerSessionCommands } from "./session-commands.js";
21
+ import { registerCompaction } from "./compaction.js";
22
+ import { registerCapture } from "./capture.js";
23
+ import { registerRenderDefaults } from "./hooks.js";
24
+ import { registerDefaultToolRenderers } from "./default-renderers.js";
25
+ import * as os from "node:os";
26
+ import * as path from "node:path";
27
+
28
+ function parseArgs(argv: string[]): AppConfig & { extensions?: string[] } {
29
+ let model: string | undefined;
30
+ let apiKey: string | undefined = process.env.OPENAI_API_KEY ?? process.env.OPENROUTER_API_KEY;
31
+ let baseURL: string | undefined = process.env.OPENAI_BASE_URL;
32
+ let provider: string | undefined;
33
+ let backend: string | undefined;
34
+ const extensions: string[] = [];
35
+
36
+ for (let i = 0; i < argv.length; i++) {
37
+ const a = argv[i];
38
+ if (a === "--model" && argv[i + 1]) model = argv[++i];
39
+ else if (a === "--api-key" && argv[i + 1]) apiKey = argv[++i];
40
+ else if (a === "--base-url" && argv[i + 1]) baseURL = argv[++i];
41
+ else if (a === "--provider" && argv[i + 1]) provider = argv[++i];
42
+ else if (a === "--backend" && argv[i + 1]) backend = argv[++i];
43
+ else if ((a === "-e" || a === "--extensions") && argv[i + 1]) {
44
+ extensions.push(...argv[++i]!.split(",").map(s => s.trim()).filter(Boolean));
45
+ } else if (a === "-h" || a === "--help") {
46
+ process.stdout.write(`ashi — ash backend, pi-tui frontend\n\n` +
47
+ `Usage: ashi [--provider <name>] [--model <id>] [--api-key <key>] [--base-url <url>]\n` +
48
+ ` [--backend <name>] [-e <ext>[,<ext>...]]\n\n` +
49
+ `Reads ~/.agent-sh/settings.json for providers and defaults.\n`);
50
+ process.exit(0);
51
+ }
52
+ }
53
+
54
+ return { shell: "/bin/sh", model, apiKey, baseURL, provider, backend, extensions };
55
+ }
56
+
57
+ async function main(): Promise<void> {
58
+ const config = parseArgs(process.argv.slice(2));
59
+
60
+ if (!process.stdin.isTTY) {
61
+ process.stderr.write("ashi requires a TTY for interactive rendering.\n");
62
+ process.exit(1);
63
+ }
64
+
65
+ const cwd = process.cwd();
66
+ const cwdSlug = cwd.replace(/\//g, "-").replace(/^-/, "");
67
+ const sessionsDir = path.join(os.homedir(), ".agent-sh", "ashi", "history", cwdSlug, "sessions");
68
+ const store = new MultiSessionStore(sessionsDir, cwd);
69
+ const getStore = (): MultiSessionStore => store;
70
+
71
+ const core = createCore({ ...config, history: new NoopHistory() });
72
+
73
+ let stopFrontend: (() => void) | null = null;
74
+
75
+ const cleanup = (): void => {
76
+ try { stopFrontend?.(); } catch { /* ignore */ }
77
+ try { core.kill(); } catch { /* ignore */ }
78
+ if (process.stdin.isTTY) {
79
+ try { process.stdin.setRawMode(false); } catch { /* ignore */ }
80
+ }
81
+ process.exit(0);
82
+ };
83
+
84
+ const ctx = core.extensionContext({ quit: cleanup });
85
+
86
+ activateAgent(ctx);
87
+ await loadBuiltinExtensions(ctx);
88
+
89
+ const loaded = await loadExtensions(ctx, config.extensions);
90
+ core.bus.emit("core:extensions-loaded", { names: loaded });
91
+
92
+ const { names: backendNames } = core.bus.emitPipe("config:get-backends", {
93
+ names: [] as string[], active: null as string | null,
94
+ });
95
+ if (backendNames.length === 0) {
96
+ process.stderr.write("ashi: no agent backend registered. Set OPENAI_API_KEY or OPENROUTER_API_KEY, or configure ~/.agent-sh/settings.json.\n");
97
+ process.exit(1);
98
+ }
99
+
100
+ const capture = registerCapture(ctx, getStore);
101
+ registerCompaction(ctx, getStore, capture);
102
+ registerRenderDefaults(ctx);
103
+ registerDefaultToolRenderers(ctx);
104
+
105
+ ctx.advise("conversation:format-prior-history", () => null);
106
+ ctx.advise("system-prompt:build", (base) => `${base}\n\n<cwd>${process.cwd()}</cwd>`);
107
+
108
+ const handle = mountAshi(ctx, getStore, capture);
109
+ stopFrontend = handle.stop;
110
+
111
+ registerForkCommands(ctx, getStore, handle.openTreePicker, handle.rebuildChat, capture);
112
+ registerSessionCommands(ctx, getStore, capture, {
113
+ openSessionPicker: handle.openSessionPicker,
114
+ rebuildChat: handle.rebuildChat,
115
+ });
116
+
117
+ await core.activateBackend(config.backend ?? getSettings().defaultBackend);
118
+
119
+ process.on("SIGTERM", cleanup);
120
+ process.on("SIGHUP", cleanup);
121
+ }
122
+
123
+ main().catch((err) => {
124
+ process.stderr.write(`ashi fatal: ${err?.stack ?? err}\n`);
125
+ process.exit(1);
126
+ });
@@ -0,0 +1,82 @@
1
+ import type { ExtensionContext } from "agent-sh/types";
2
+ import type { MultiSessionStore } from "./multi-session-store.js";
3
+ import type { AgentMessage } from "./session-store.js";
4
+ import type { Capture } from "./capture.js";
5
+
6
+ export function registerForkCommands(
7
+ ctx: ExtensionContext,
8
+ getStore: () => MultiSessionStore,
9
+ openTreePicker: () => Promise<void>,
10
+ rebuildChat: () => Promise<void>,
11
+ capture: Capture,
12
+ ): void {
13
+ const { bus } = ctx;
14
+
15
+ ctx.registerCommand("fork", "Rewind and branch: /fork (interactive picker) or /fork <id-prefix>", async (args) => {
16
+ const arg = args.trim();
17
+ if (arg === "") {
18
+ await openTreePicker();
19
+ return;
20
+ }
21
+ const branch = getStore().current().getBranch();
22
+ const matches = branch.filter((e) => e.id.startsWith(arg));
23
+ if (matches.length === 0) {
24
+ bus.emit("ui:error", { message: `fork: no entry matches "${arg}"` });
25
+ return;
26
+ }
27
+ if (matches.length > 1) {
28
+ bus.emit("ui:error", { message: `fork: ambiguous prefix "${arg}" matches ${matches.length} entries` });
29
+ return;
30
+ }
31
+ const target = matches[0]!;
32
+ getStore().current().setActiveLeaf(target.id);
33
+ applyBranchMessages(ctx, getStore, capture);
34
+ bus.emit("ui:info", { message: `fork: rewound to ${target.id}` });
35
+ await rebuildChat();
36
+ });
37
+
38
+ ctx.registerCommand("branch", "Show the active branch (root → leaf)", async () => {
39
+ const branch = getStore().current().getBranch();
40
+ if (branch.length === 0) {
41
+ bus.emit("ui:info", { message: "branch: empty" });
42
+ return;
43
+ }
44
+ const lines = branch.map((e) => {
45
+ if (e.type === "session") return `[${e.id}] session start (${e.cwd})`;
46
+ if (e.type === "compaction") return `[${e.id}] compaction (firstKept=${e.firstKeptId})`;
47
+ const msg = (e as { message: AgentMessage }).message;
48
+ const text = typeof msg.content === "string" ? msg.content : "";
49
+ return `[${e.id}] ${msg.role}: ${text.slice(0, 60)}`;
50
+ });
51
+ bus.emit("ui:info", { message: `branch (${branch.length} entries):\n${lines.join("\n")}` });
52
+ });
53
+ }
54
+
55
+ export function applyBranchMessages(
56
+ ctx: ExtensionContext,
57
+ getStore: () => MultiSessionStore,
58
+ capture: Capture,
59
+ ): void {
60
+ const store = getStore().current();
61
+ ctx.call("conversation:replace-messages", store.buildMessages());
62
+
63
+ const branch = store.getBranch();
64
+ let compaction: { firstKeptId: string } | null = null;
65
+ for (let i = branch.length - 1; i >= 0; i--) {
66
+ if (branch[i]!.type === "compaction") {
67
+ compaction = branch[i] as { firstKeptId: string };
68
+ break;
69
+ }
70
+ }
71
+ const ids: (string | null)[] = [];
72
+ if (compaction) {
73
+ ids.push(null);
74
+ const startIdx = branch.findIndex((e) => e.id === compaction!.firstKeptId);
75
+ for (let i = Math.max(0, startIdx); i < branch.length; i++) {
76
+ if (branch[i]!.type === "message") ids.push(branch[i]!.id);
77
+ }
78
+ } else {
79
+ for (const e of branch) if (e.type === "message") ids.push(e.id);
80
+ }
81
+ capture.resetTo(ids);
82
+ }