@alisio/alisio-code 0.1.0-alpha.11 → 0.1.0-alpha.12

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.
package/dist/tui/app.js CHANGED
@@ -5,6 +5,8 @@ import { MAX_ATTACHMENTS_PER_MESSAGE, MAX_IMAGE_BYTES, pasteImageFromClipboard,
5
5
  import { copyText, nodeSpawn } from "./clipboard.js";
6
6
  import { AttachmentsBar, BannerBlock, clock, Footer, Header, QuestionPanel, Switch, TranscriptSync, TreePanel, } from "./components.js";
7
7
  import { ConnectInputPrompt } from "./connect-input.js";
8
+ import { bounded, EXIT_PENDING_CAP_MS, EXIT_SESSION_END_CAP_MS } from "./exit.js";
9
+ import { BRANCH_REFRESH_MS, createBranchCache } from "./git-branch.js";
8
10
  import { initialPanelState, reducePanel, visibleRows } from "./panel.js";
9
11
  import { summarizeAnswers } from "./questions.js";
10
12
  import { InteractiveQueue } from "./queue.js";
@@ -153,6 +155,21 @@ export async function runTui(options) {
153
155
  }, ms);
154
156
  tui.requestRender();
155
157
  };
158
+ // Git branch of the workspace for the header. Reading refs is a read operation, so it stays on
159
+ // under --read-only; the TTL cache means git is spawned at most once per ~10s, never per frame,
160
+ // and a missing repo/git or a slow spawn simply yields no branch segment.
161
+ const branchCache = createBranchCache();
162
+ let branchName;
163
+ const refreshBranch = () => {
164
+ void branchCache.read(app.workspace).then((branch) => {
165
+ if (branch !== branchName) {
166
+ branchName = branch;
167
+ tui.requestRender();
168
+ }
169
+ });
170
+ };
171
+ refreshBranch();
172
+ const branchTimer = setInterval(refreshBranch, BRANCH_REFRESH_MS);
156
173
  const headerInfo = () => {
157
174
  const policy = app.runner.policy, ask = app.runner.approvals;
158
175
  return {
@@ -162,6 +179,7 @@ export async function runTui(options) {
162
179
  provider: app.providers.get(activeProvider?.id ?? "")?.name,
163
180
  cwd: shortenPath(app.workspace, homedir()),
164
181
  session: shortId(session),
182
+ branch: branchName,
165
183
  write: policy.write ? "on" : ask ? "ask" : "off",
166
184
  process: policy.process ? "on" : ask ? "ask" : "off",
167
185
  mcp: app.mcpRuntimePermission() === "granted",
@@ -479,7 +497,7 @@ export async function runTui(options) {
479
497
  const endSession = async (reason) => {
480
498
  if (!view.stats.runs || !app.plugins.hasSessionEndHooks)
481
499
  return;
482
- flashHint("Running session-end plugin hooks (bounded by pluginHooks.sessionEndTimeoutMs)…", 60_000);
500
+ flashHint("Running session-end plugin hooks…", 60_000);
483
501
  tui.renderNow?.();
484
502
  try {
485
503
  const { failures } = await app.endSession(session, reason);
@@ -499,8 +517,12 @@ export async function runTui(options) {
499
517
  return;
500
518
  stopping = true;
501
519
  controller?.abort(new Error("Exiting"));
502
- await Promise.race([pending?.catch(() => { }), new Promise((r) => setTimeout(r, 3000))]);
503
- await endSession("exit");
520
+ // Exit must feel instant: an in-flight turn gets up to 3s (the abort above settles it
521
+ // immediately in practice), then session-end hooks get a short cap — /clear still honors
522
+ // the full pluginHooks.sessionEndTimeoutMs. app.close() later in the main path is itself
523
+ // parallel and capped, so the whole exit path is bounded end to end.
524
+ await bounded(pending?.catch(() => { }), EXIT_PENDING_CAP_MS);
525
+ await bounded(endSession("exit"), EXIT_SESSION_END_CAP_MS);
504
526
  resolveExit();
505
527
  };
506
528
  const askInput = (input) => new Promise((resolve) => {
@@ -1540,6 +1562,7 @@ export async function runTui(options) {
1540
1562
  }
1541
1563
  finally {
1542
1564
  clearInterval(ticker);
1565
+ clearInterval(branchTimer);
1543
1566
  clearTimeout(hintTimer);
1544
1567
  process.off("SIGTERM", onSignal);
1545
1568
  process.off("SIGHUP", onSignal);
@@ -17,6 +17,8 @@ export interface HeaderInfo {
17
17
  provider?: string;
18
18
  cwd: string;
19
19
  session: string;
20
+ /** Git branch (or short SHA on a detached HEAD) of the session workspace; undefined when unavailable. */
21
+ branch?: string;
20
22
  write: PermissionState;
21
23
  process: PermissionState;
22
24
  mcp: boolean;
@@ -1,5 +1,7 @@
1
1
  import { Container, getCapabilities, Image, Key, Markdown, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, } from "@earendil-works/pi-tui";
2
+ import { terminalCapabilities } from "../banner.js";
2
3
  import { attachmentCaption, MAX_ATTACHMENTS_PER_MESSAGE, } from "./attachments.js";
4
+ import { branchDisplay } from "./git-branch.js";
3
5
  import { initialQuestionState, reduceQuestions, } from "./questions.js";
4
6
  import { contextLevel, contextPercent, editSummary, fitSegments, formatContext, formatDuration, formatTokens, } from "./state.js";
5
7
  import { imageTheme, levelColor, markdownTheme, style } from "./theme.js";
@@ -53,6 +55,19 @@ export class Header {
53
55
  const line2 = line([
54
56
  { text: i.cwd, priority: 6, paint: style.cyan },
55
57
  { text: `session ${i.session}`, priority: 4, paint: style.gray },
58
+ // Priority 3 (below session's 4): the branch is ambient context, so on narrow terminals it
59
+ // drops first — before the session ID and permissions. At equal priority fitSegments drops
60
+ // the FIRST lowest-priority segment, which would mis-drop the session when branch sits
61
+ // after it, so the branch keeps a strictly lower priority instead.
62
+ ...(i.branch
63
+ ? [
64
+ {
65
+ text: branchDisplay(i.branch, terminalCapabilities({ env: process.env, columns: width, tty: true }).unicode),
66
+ priority: 3,
67
+ paint: style.cyan,
68
+ },
69
+ ]
70
+ : []),
56
71
  ...perms,
57
72
  ], width);
58
73
  return fit([line1, line2, style.gray("─".repeat(Math.max(0, width)))], width);
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Interactive-exit bounds: `/exit`, double Ctrl+C on an empty input, Ctrl+D and SIGINT/SIGTERM
3
+ * must feel instant, so each shutdown step is raced against a cap instead of awaiting teardown
4
+ * without limits. The plugin host's own hook timeout (`pluginHooks.sessionEndTimeoutMs`) still
5
+ * applies to `/clear`; exit deliberately uses shorter user-facing caps on the same hooks.
6
+ */
7
+ export declare const EXIT_PENDING_CAP_MS = 3000;
8
+ export declare const EXIT_SESSION_END_CAP_MS = 1500;
9
+ /** Resolves when `work` settles or after `capMs`, whichever comes first; never rejects. */
10
+ export declare function bounded(work: Promise<unknown> | undefined, capMs: number): Promise<void>;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Interactive-exit bounds: `/exit`, double Ctrl+C on an empty input, Ctrl+D and SIGINT/SIGTERM
3
+ * must feel instant, so each shutdown step is raced against a cap instead of awaiting teardown
4
+ * without limits. The plugin host's own hook timeout (`pluginHooks.sessionEndTimeoutMs`) still
5
+ * applies to `/clear`; exit deliberately uses shorter user-facing caps on the same hooks.
6
+ */
7
+ export const EXIT_PENDING_CAP_MS = 3_000;
8
+ export const EXIT_SESSION_END_CAP_MS = 1_500;
9
+ /** Resolves when `work` settles or after `capMs`, whichever comes first; never rejects. */
10
+ export function bounded(work, capMs) {
11
+ return Promise.race([
12
+ Promise.resolve(work).catch(() => { }),
13
+ new Promise((resolve) => setTimeout(resolve, capMs)),
14
+ ]).then(() => undefined);
15
+ }
@@ -0,0 +1,48 @@
1
+ import { execFile } from "node:child_process";
2
+ /** Hard cap for branch names shown in the header, so a hostile/long ref never breaks layout. */
3
+ export declare const BRANCH_MAX_LENGTH = 24;
4
+ /** How long a resolved branch is reused before git is asked again (avoids spawning per frame). */
5
+ export declare const BRANCH_TTL_MS = 10000;
6
+ /** Re-check cadence; matches the cache TTL so the header follows branch switches within ~10s. */
7
+ export declare const BRANCH_REFRESH_MS = 10000;
8
+ /** Bounded spawn: a slow or hung git must never stall the TUI. */
9
+ export declare const BRANCH_TIMEOUT_MS = 800;
10
+ type Spawn = typeof execFile;
11
+ /**
12
+ * Strips ANSI escapes and control characters, then truncates to `BRANCH_MAX_LENGTH` chars with an
13
+ * ellipsis. Branch names come from the workspace repository, not from user input, but a hostile or
14
+ * corrupted ref must never inject escape sequences or overflow the header line.
15
+ */
16
+ export declare function sanitizeBranchName(name: string): string;
17
+ /** Header segment text: `⎇ main` on unicode terminals, `branch main` otherwise. */
18
+ export declare function branchDisplay(branch: string, unicode: boolean): string;
19
+ export interface ReadBranchOptions {
20
+ timeoutMs?: number;
21
+ /** Injectable for tests; defaults to node:child_process execFile. */
22
+ spawn?: Spawn;
23
+ }
24
+ /**
25
+ * Reads the branch of the git repository at `workspace` (read-only ref query, safe under
26
+ * `--read-only`). Returns the branch name, the short commit SHA on a detached HEAD, or `undefined`
27
+ * when the directory is missing, is not a git repository, git itself is unavailable, or the call
28
+ * times out. All failures are silent: stderr is captured and never printed.
29
+ */
30
+ export declare function readGitBranch(workspace: string, options?: ReadBranchOptions): Promise<string | undefined>;
31
+ export interface BranchCache {
32
+ /** Cached branch for `workspace`, or a fresh read when the entry expired; dedupes in-flight reads. */
33
+ read(workspace: string): Promise<string | undefined>;
34
+ clear(workspace: string): void;
35
+ }
36
+ export interface BranchCacheOptions {
37
+ ttlMs?: number;
38
+ timeoutMs?: number;
39
+ spawn?: Spawn;
40
+ /** Injectable clock for expiry tests. */
41
+ now?: () => number;
42
+ }
43
+ /**
44
+ * TTL cache keyed by workspace so the TUI never spawns git on every frame: one read per workspace
45
+ * per `ttlMs` (hits and misses are both cached), with a single in-flight promise per key.
46
+ */
47
+ export declare function createBranchCache(options?: BranchCacheOptions): BranchCache;
48
+ export {};
@@ -0,0 +1,99 @@
1
+ import { execFile } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ /** Hard cap for branch names shown in the header, so a hostile/long ref never breaks layout. */
4
+ export const BRANCH_MAX_LENGTH = 24;
5
+ /** How long a resolved branch is reused before git is asked again (avoids spawning per frame). */
6
+ export const BRANCH_TTL_MS = 10_000;
7
+ /** Re-check cadence; matches the cache TTL so the header follows branch switches within ~10s. */
8
+ export const BRANCH_REFRESH_MS = BRANCH_TTL_MS;
9
+ /** Bounded spawn: a slow or hung git must never stall the TUI. */
10
+ export const BRANCH_TIMEOUT_MS = 800;
11
+ /**
12
+ * Strips ANSI escapes and control characters, then truncates to `BRANCH_MAX_LENGTH` chars with an
13
+ * ellipsis. Branch names come from the workspace repository, not from user input, but a hostile or
14
+ * corrupted ref must never inject escape sequences or overflow the header line.
15
+ */
16
+ export function sanitizeBranchName(name) {
17
+ const clean = name
18
+ .replace(/\x1b\[[0-9;]*[A-Za-z]/g, "")
19
+ .replace(/[\u0000-\u001f\u007f]/g, "")
20
+ .trim();
21
+ const chars = [...clean];
22
+ return chars.length > BRANCH_MAX_LENGTH
23
+ ? `${chars.slice(0, BRANCH_MAX_LENGTH - 1).join("")}…`
24
+ : clean;
25
+ }
26
+ /** Header segment text: `⎇ main` on unicode terminals, `branch main` otherwise. */
27
+ export function branchDisplay(branch, unicode) {
28
+ return `${unicode ? "⎇" : "branch"} ${branch}`;
29
+ }
30
+ function runGit(spawn, args, cwd, timeoutMs) {
31
+ return new Promise((resolve, reject) => {
32
+ spawn("git", args, { cwd, timeout: timeoutMs, windowsHide: true }, (error, stdout) => {
33
+ if (error)
34
+ reject(error);
35
+ else
36
+ resolve(stdout);
37
+ });
38
+ });
39
+ }
40
+ /**
41
+ * Reads the branch of the git repository at `workspace` (read-only ref query, safe under
42
+ * `--read-only`). Returns the branch name, the short commit SHA on a detached HEAD, or `undefined`
43
+ * when the directory is missing, is not a git repository, git itself is unavailable, or the call
44
+ * times out. All failures are silent: stderr is captured and never printed.
45
+ */
46
+ export async function readGitBranch(workspace, options = {}) {
47
+ const timeoutMs = options.timeoutMs ?? BRANCH_TIMEOUT_MS;
48
+ if (!existsSync(workspace))
49
+ return undefined;
50
+ const spawn = options.spawn ?? execFile;
51
+ try {
52
+ const ref = (await runGit(spawn, ["rev-parse", "--abbrev-ref", "HEAD"], workspace, timeoutMs)).trim();
53
+ if (ref && ref !== "HEAD")
54
+ return sanitizeBranchName(ref);
55
+ if (ref === "HEAD") {
56
+ const sha = (await runGit(spawn, ["rev-parse", "--short", "HEAD"], workspace, timeoutMs)).trim();
57
+ return sha ? sanitizeBranchName(sha) : undefined;
58
+ }
59
+ return undefined;
60
+ }
61
+ catch {
62
+ // Not a repository, git missing, or timed out: the header simply shows no branch segment.
63
+ return undefined;
64
+ }
65
+ }
66
+ /**
67
+ * TTL cache keyed by workspace so the TUI never spawns git on every frame: one read per workspace
68
+ * per `ttlMs` (hits and misses are both cached), with a single in-flight promise per key.
69
+ */
70
+ export function createBranchCache(options = {}) {
71
+ const ttlMs = options.ttlMs ?? BRANCH_TTL_MS;
72
+ const now = options.now ?? Date.now;
73
+ const entries = new Map();
74
+ const inflight = new Map();
75
+ const read = (workspace) => {
76
+ const hit = entries.get(workspace);
77
+ if (hit && hit.expires > now())
78
+ return Promise.resolve(hit.branch);
79
+ const pending = inflight.get(workspace);
80
+ if (pending)
81
+ return pending;
82
+ const promise = readGitBranch(workspace, {
83
+ timeoutMs: options.timeoutMs,
84
+ spawn: options.spawn,
85
+ })
86
+ .then((branch) => {
87
+ entries.set(workspace, { branch, expires: now() + ttlMs });
88
+ inflight.delete(workspace);
89
+ return branch;
90
+ })
91
+ .catch(() => {
92
+ inflight.delete(workspace);
93
+ return undefined;
94
+ });
95
+ inflight.set(workspace, promise);
96
+ return promise;
97
+ };
98
+ return { read, clear: (workspace) => void entries.delete(workspace) };
99
+ }
@@ -11,7 +11,7 @@ export const WEBSEARCH_PROVIDERS = [
11
11
  export const defaultConfig = {
12
12
  compaction: { auto: true, threshold: 0.85, keepTurns: 2, maxOutputTokens: 16_000 },
13
13
  context: { claudeMdFallback: false, maxBytes: 32 * 1024 },
14
- limits: { maxTurns: 20, maxOutputTokens: 4_096, maxContextChars: 160_000, timeoutMs: 300_000 },
14
+ limits: { maxTurns: 20, maxOutputTokens: 16_384, maxContextChars: 800_000, timeoutMs: 300_000 },
15
15
  tui: { paddingX: 1, skillSlashCommands: true },
16
16
  mcp: { allow: false },
17
17
  websearch: { provider: undefined },
@@ -128,7 +128,7 @@ export const SETTINGS_DEFINITIONS = [
128
128
  label: "Context char budget",
129
129
  category: "Limits",
130
130
  valueType: "number",
131
- values: [80_000, 120_000, 160_000, 240_000, 320_000],
131
+ values: [80_000, 120_000, 160_000, 240_000, 320_000, 800_000],
132
132
  read: (config) => config.limits.maxContextChars,
133
133
  description: "Hard context limit in characters (instruction files + transcript + tool list) per run; the budget fallback that auto-compaction measures when the model window is unknown. Applied from the next run.",
134
134
  },
package/dist/tui/state.js CHANGED
@@ -591,7 +591,7 @@ export function reduceEvent(state, event) {
591
591
  case "response_truncated":
592
592
  return addItem(state, {
593
593
  kind: "notice",
594
- text: "Response cut by max output tokens — the answer may be incomplete. Raise limits.maxOutputTokens to allow longer answers.",
594
+ text: "Response cut by max output tokens — the answer may be incomplete. Raise limits.maxOutputTokens (/settings → Agent max output tokens) to allow longer answers.",
595
595
  });
596
596
  case "model_changed":
597
597
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alisio/alisio-code",
3
- "version": "0.1.0-alpha.11",
3
+ "version": "0.1.0-alpha.12",
4
4
  "description": "Alisio: an extensible, provider-agnostic coding-agent harness for your terminal. TUI, OpenAI-compatible providers, permissioned local tools, context compaction, persistent memory and a typed plugin SDK.",
5
5
  "author": "Gustavo Gutiérrez",
6
6
  "license": "MIT",
@@ -45,14 +45,14 @@
45
45
  "alisio": "./dist/main.js"
46
46
  },
47
47
  "dependencies": {
48
- "@alisio/core": "0.1.0-alpha.9",
49
- "@alisio/plugin-deepseek": "0.1.0-alpha.7",
48
+ "@alisio/core": "0.1.0-alpha.10",
49
+ "@alisio/plugin-deepseek": "0.1.0-alpha.8",
50
50
  "@alisio/plugin-memory": "0.1.0-alpha.6",
51
- "@alisio/plugin-openai-compatible": "0.1.0-alpha.7",
52
- "@alisio/plugin-opencode": "0.1.0-alpha.6",
53
- "@alisio/plugin-opencode-go": "0.1.0-alpha.6",
54
- "@alisio/plugin-subagents": "0.1.0-alpha.6",
55
- "@alisio/sdk": "0.1.0-alpha.5",
51
+ "@alisio/plugin-openai-compatible": "0.1.0-alpha.8",
52
+ "@alisio/plugin-opencode": "0.1.0-alpha.7",
53
+ "@alisio/plugin-opencode-go": "0.1.0-alpha.7",
54
+ "@alisio/plugin-subagents": "0.1.0-alpha.7",
55
+ "@alisio/sdk": "0.1.0-alpha.6",
56
56
  "@earendil-works/pi-tui": "0.87.1",
57
57
  "commander": "15.0.0"
58
58
  },