ever-terminal 1.0.1 → 1.0.2

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.
@@ -1,7 +1,10 @@
1
1
  import { readFileSync, existsSync, readdirSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { homedir } from "node:os";
4
+ import { exec } from "node:child_process";
5
+ import { promisify } from "node:util";
4
6
  import { listSessions, getSessionMessages, } from "@anthropic-ai/claude-agent-sdk";
7
+ const execAsync = promisify(exec);
5
8
  import { ClaudeSession } from "./session.js";
6
9
  /** Find the jsonl file for a Claude session by scanning project dirs. */
7
10
  function findSessionFile(sessionId) {
@@ -55,11 +58,11 @@ export function claudeSessionStatus(sessionId) {
55
58
  if (text.includes("Request interrupted by user"))
56
59
  return "idle";
57
60
  }
58
- if (last.timestamp) {
59
- const ageMs = Date.now() - new Date(last.timestamp).getTime();
60
- if (ageMs > 120_000)
61
- return "idle";
62
- }
61
+ if (!last.timestamp)
62
+ return "idle"; // ponytail: no timestamp → not in-progress
63
+ const ageMs = Date.now() - new Date(last.timestamp).getTime();
64
+ if (ageMs > 120_000)
65
+ return "idle";
63
66
  return "busy";
64
67
  }
65
68
  // ── Provider factory ────────────────────────────────────
@@ -108,11 +111,11 @@ export function createClaudeProvider(emit) {
108
111
  "";
109
112
  return { sessionId: resolvedId, provider: "claude" };
110
113
  }
111
- function respondPermission(sessionId, decision) {
112
- sessions.get(sessionId)?.respondPermission(decision);
114
+ function respondPermission(sessionId, decision, toolUseId) {
115
+ sessions.get(sessionId)?.respondPermission(decision, toolUseId);
113
116
  }
114
- function respondQuestion(sessionId, answer) {
115
- sessions.get(sessionId)?.respondQuestion(answer);
117
+ function respondQuestion(sessionId, answer, toolUseId) {
118
+ sessions.get(sessionId)?.respondQuestion(answer, toolUseId);
116
119
  }
117
120
  function interrupt(sessionId) {
118
121
  sessions.get(sessionId)?.interrupt();
@@ -144,9 +147,6 @@ export function createClaudeProvider(emit) {
144
147
  }));
145
148
  }
146
149
  async function getInfo() {
147
- const { exec } = await import("node:child_process");
148
- const { promisify } = await import("node:util");
149
- const execAsync = promisify(exec);
150
150
  let version = "";
151
151
  try {
152
152
  const { stdout } = await execAsync("claude --version", {
@@ -2,6 +2,8 @@ import { query } from "@anthropic-ai/claude-agent-sdk";
2
2
  import { existsSync } from "node:fs";
3
3
  import { summarizeClaudeToolCall } from "./summarize.js";
4
4
  import { debugLog } from "../debug.js";
5
+ // ponytail: shell metachar check — rejects chained commands, keeps the regex simple
6
+ const SHELL_META = /[&|;`$><\n]/;
5
7
  function buildClaudePermissionOptions(suggestions, description) {
6
8
  const options = [{ text: "Yes", key: "allow" }];
7
9
  const alwaysText = describeClaudePermissionSuggestions(suggestions, description);
@@ -71,8 +73,8 @@ export class ClaudeSession {
71
73
  statsTimer = null;
72
74
  queryHandle = null;
73
75
  runningQuery = null;
74
- pendingPermissions = [];
75
- pendingQuestions = [];
76
+ pendingPermissions = new Map();
77
+ pendingQuestions = new Map();
76
78
  alwaysAllowedTools = new Set();
77
79
  pendingToolCalls = new Map();
78
80
  /** Tracks the type of the currently-open content block ("thinking" | "text" | null). */
@@ -99,8 +101,7 @@ export class ClaudeSession {
99
101
  /** Tracked session status: 'awaiting' if there's an unanswered permission
100
102
  * request or user question, otherwise 'busy'/'idle' based on _busy. */
101
103
  get status() {
102
- if (this.pendingPermissions.length > 0 ||
103
- this.pendingQuestions.length > 0) {
104
+ if (this.pendingPermissions.size > 0 || this.pendingQuestions.size > 0) {
104
105
  return "awaiting";
105
106
  }
106
107
  return this._busy ? "busy" : "idle";
@@ -113,10 +114,11 @@ export class ClaudeSession {
113
114
  this.idResolve = resolve;
114
115
  });
115
116
  }
116
- return Promise.race([
117
- this.idPromise,
118
- new Promise((_, reject) => setTimeout(() => reject(new Error("Timed out waiting for session ID")), timeoutMs)),
119
- ]);
117
+ let timer;
118
+ const timeout = new Promise((_, reject) => {
119
+ timer = setTimeout(() => reject(new Error("Timed out waiting for session ID")), timeoutMs);
120
+ });
121
+ return Promise.race([this.idPromise, timeout]).finally(() => clearTimeout(timer));
120
122
  }
121
123
  onIdReady(cb) {
122
124
  if (this.sessionId) {
@@ -153,37 +155,50 @@ export class ClaudeSession {
153
155
  }
154
156
  /**
155
157
  * Wait for a user response with timeout and SDK abort signal as fallbacks.
156
- * Multiple requests can be pending (e.g. subagents); resolved FIFO.
158
+ * Multiple requests can be pending (e.g. subagents); keyed by toolUseId.
157
159
  */
158
- waitForUser(queue, signal, timeoutMs, defaultValue) {
160
+ waitForUser(map, toolUseId, signal, timeoutMs, defaultValue) {
159
161
  return new Promise((resolve) => {
160
162
  let settled = false;
161
- const entry = (value) => finish(value);
162
163
  const finish = (value) => {
163
164
  if (settled)
164
165
  return;
165
166
  settled = true;
166
167
  clearTimeout(timer);
167
168
  signal.removeEventListener("abort", onAbort);
168
- const idx = queue.indexOf(entry);
169
- if (idx !== -1)
170
- queue.splice(idx, 1);
169
+ map.delete(toolUseId);
171
170
  resolve(value);
172
171
  };
173
172
  const timer = setTimeout(() => finish(defaultValue), timeoutMs);
174
173
  const onAbort = () => finish(defaultValue);
175
174
  signal.addEventListener("abort", onAbort, { once: true });
176
- queue.push(entry);
175
+ map.set(toolUseId, (value) => finish(value));
177
176
  });
178
177
  }
179
- respondPermission(decision) {
180
- this.pendingPermissions.shift()?.({
178
+ respondPermission(decision, toolUseId) {
179
+ const resolver = this.resolveFromPending(this.pendingPermissions, toolUseId);
180
+ resolver?.({
181
181
  allow: decision === "allow" || decision === "allowAlways",
182
182
  allowAlways: decision === "allowAlways",
183
183
  });
184
184
  }
185
- respondQuestion(answer) {
186
- this.pendingQuestions.shift()?.(answer);
185
+ respondQuestion(answer, toolUseId) {
186
+ const resolver = this.resolveFromPending(this.pendingQuestions, toolUseId);
187
+ resolver?.(answer);
188
+ }
189
+ /** Resolve a pending entry by toolUseId (exact match) or fall back to the oldest entry. */
190
+ resolveFromPending(map, toolUseId) {
191
+ if (toolUseId && map.has(toolUseId)) {
192
+ const fn = map.get(toolUseId);
193
+ map.delete(toolUseId);
194
+ return fn;
195
+ }
196
+ // ponytail: fallback to oldest entry for clients that don't send toolUseId yet
197
+ const first = map.entries().next();
198
+ if (first.done)
199
+ return undefined;
200
+ map.delete(first.value[0]);
201
+ return first.value[1];
187
202
  }
188
203
  stopStatsTimer() {
189
204
  if (this.statsTimer) {
@@ -246,7 +261,7 @@ export class ClaudeSession {
246
261
  options: {
247
262
  resume: this.sessionId,
248
263
  cwd: this.lockedCwd,
249
- model: "claude-opus-4-6",
264
+ model: process.env.CLAUDE_MODEL || "claude-opus-4-8",
250
265
  allowedTools: [
251
266
  "Read",
252
267
  "Edit",
@@ -283,7 +298,7 @@ export class ClaudeSession {
283
298
  },
284
299
  includePartialMessages: true,
285
300
  maxTurns: 50,
286
- settingSources: ["user", "project"],
301
+ // ponytail: omit settingSources to load all three (user/project/local), matching CLI defaults
287
302
  stderr: (data) => {
288
303
  const trimmed = data.trim();
289
304
  if (trimmed)
@@ -337,6 +352,7 @@ export class ClaudeSession {
337
352
  });
338
353
  }
339
354
  interrupt() {
355
+ this.promptQueue.length = 0;
340
356
  this.queryHandle?.interrupt().catch(() => { });
341
357
  }
342
358
  async close() {
@@ -346,8 +362,9 @@ export class ClaudeSession {
346
362
  };
347
363
  console.log(`[session] close: session=${this.sessionId ?? "none"} had=${JSON.stringify(had)}`);
348
364
  this.stopStatsTimer();
349
- this.pendingPermissions.length = 0;
350
- this.pendingQuestions.length = 0;
365
+ this.pendingPermissions.clear();
366
+ this.pendingQuestions.clear();
367
+ this.pendingToolCalls.clear();
351
368
  this.promptQueue.length = 0;
352
369
  if (this.queryHandle) {
353
370
  this.queryHandle.close();
@@ -364,6 +381,8 @@ export class ClaudeSession {
364
381
  async reset(cwd) {
365
382
  await this.close();
366
383
  this.sessionId = undefined;
384
+ this.idPromise = null;
385
+ this.idResolve = null;
367
386
  this.lockedCwd = cwd;
368
387
  }
369
388
  // ── Tool handlers ──────────────────────────────────
@@ -376,21 +395,23 @@ export class ClaudeSession {
376
395
  console.log(`[session] Auto-approve (allowAlways): ${toolName}`);
377
396
  return { behavior: "allow", updatedInput: input };
378
397
  }
379
- const PERMISSION_TOOLS = new Set([
380
- "KillShell",
381
- "Config",
382
- "Mcp",
383
- "RemoteTrigger",
398
+ // ponytail: known safe read-only tools — auto-allow, no prompt
399
+ const READ_ONLY_TOOLS = new Set([
400
+ "Read",
401
+ "Glob",
402
+ "Grep",
403
+ "WebSearch",
404
+ "WebFetch",
405
+ "ToolSearch",
406
+ "ListMcpResources",
407
+ "ReadMcpResource",
408
+ "ExitPlanMode",
409
+ "TaskOutput",
410
+ "TaskGet",
411
+ "TaskList",
384
412
  ]);
385
- if (PERMISSION_TOOLS.has(toolName)) {
386
- return this.handlePermissionConfirm(toolName, input, options.toolUseID, options.signal, options.suggestions);
387
- }
388
- if (toolName === "Bash") {
389
- const cmd = String(input.command || "").trim();
390
- if (/^\s*(ls|cat|head|tail|wc|pwd|echo|printf|date|whoami|which|where|type|file|stat|du|df|env|printenv|uname|hostname|id|git\s+(status|log|diff|branch|show|remote|rev-parse))\b/.test(cmd)) {
391
- return { behavior: "allow", updatedInput: input };
392
- }
393
- return this.handlePermissionConfirm(toolName, input, options.toolUseID, options.signal, options.suggestions);
413
+ if (READ_ONLY_TOOLS.has(toolName)) {
414
+ return { behavior: "allow", updatedInput: input };
394
415
  }
395
416
  if (toolName === "TodoWrite") {
396
417
  const todos = input.todos || [];
@@ -420,8 +441,18 @@ export class ClaudeSession {
420
441
  }
421
442
  return { behavior: "allow", updatedInput: input };
422
443
  }
423
- console.log(`[session] canUseTool auto-approve: ${toolName} input_keys=${Object.keys(input).join(",")}`);
424
- return { behavior: "allow", updatedInput: input };
444
+ if (toolName === "Bash") {
445
+ const cmd = String(input.command || "").trim();
446
+ // ponytail: only auto-allow simple read-only commands with NO shell metachars
447
+ if (!SHELL_META.test(cmd) &&
448
+ /^\s*(ls|cat|head|tail|wc|pwd|echo|printf|date|whoami|which|where|type|file|stat|du|df|env|printenv|uname|hostname|id|git\s+(status|log|diff|branch|show|remote|rev-parse))\b/.test(cmd)) {
449
+ return { behavior: "allow", updatedInput: input };
450
+ }
451
+ return this.handlePermissionConfirm(toolName, input, options.toolUseID, options.signal, options.suggestions);
452
+ }
453
+ // ponytail: everything else (Write, MCP tools, Agent, Skill, etc.) — prompt the user
454
+ console.log(`[session] canUseTool prompt-required: ${toolName} input_keys=${Object.keys(input).join(",")}`);
455
+ return this.handlePermissionConfirm(toolName, input, options.toolUseID, options.signal, options.suggestions);
425
456
  }
426
457
  async handleAskUserQuestion(toolInput, toolUseID, signal) {
427
458
  const questions = toolInput.questions || [];
@@ -440,7 +471,7 @@ export class ClaudeSession {
440
471
  })),
441
472
  toolUseId: toolUseID,
442
473
  });
443
- const answer = await this.waitForUser(this.pendingQuestions, signal, 120000, "skip");
474
+ const answer = await this.waitForUser(this.pendingQuestions, toolUseID, signal, 120000, "skip");
444
475
  let answers = {};
445
476
  try {
446
477
  answers = JSON.parse(answer);
@@ -493,7 +524,7 @@ export class ClaudeSession {
493
524
  options: permissionOptions,
494
525
  suggestions: suggestions ?? null,
495
526
  });
496
- const result = await this.waitForUser(this.pendingPermissions, signal, 60000, { allow: false, allowAlways: false });
527
+ const result = await this.waitForUser(this.pendingPermissions, toolUseID, signal, 60000, { allow: false, allowAlways: false });
497
528
  let sdkDecision;
498
529
  if (result.allowAlways) {
499
530
  this.alwaysAllowedTools.add(toolName);
@@ -674,7 +705,10 @@ export class ClaudeSession {
674
705
  let outputTokens = 0;
675
706
  if (msg.modelUsage) {
676
707
  for (const m of Object.values(msg.modelUsage)) {
677
- inputTokens += m.inputTokens ?? 0;
708
+ inputTokens +=
709
+ (m.inputTokens ?? 0) +
710
+ (m.cacheReadInputTokens ?? 0) +
711
+ (m.cacheCreationInputTokens ?? 0);
678
712
  outputTokens += m.outputTokens ?? 0;
679
713
  }
680
714
  }
package/dist/cli.js CHANGED
@@ -64,6 +64,11 @@ const optionDefinitions = {
64
64
  type: "string",
65
65
  describe: "Tee all logs to a file (default: ./ever-terminal-<ts>.log)",
66
66
  },
67
+ model: {
68
+ alias: "m",
69
+ type: "string",
70
+ describe: "Claude model (default: claude-opus-4-8)",
71
+ },
67
72
  verbose: {
68
73
  type: "boolean",
69
74
  describe: "Print raw SDK messages for debugging",
@@ -178,6 +183,8 @@ async function run(argv) {
178
183
  process.env.PROJECT_DIR = resolve(argv.cwd);
179
184
  if (argv.provider)
180
185
  process.env.DEFAULT_PROVIDER = argv.provider;
186
+ if (argv.model)
187
+ process.env.CLAUDE_MODEL = argv.model;
181
188
  if (argv.verbose)
182
189
  process.env.VERBOSE = "1";
183
190
  {
@@ -136,8 +136,8 @@ router.post("/prompt", async (req, res) => {
136
136
  });
137
137
  // POST /api/permission-response
138
138
  router.post("/permission-response", (req, res) => {
139
- const { sessionId, decision, provider } = req.body ?? {};
140
- console.log(`[permission-response] sessionId=${sessionId ?? "(none)"} provider=${provider ?? "(default)"} decision=${decision ?? "deny"}`);
139
+ const { sessionId, decision, provider, toolUseId } = req.body ?? {};
140
+ console.log(`[permission-response] sessionId=${sessionId ?? "(none)"} provider=${provider ?? "(default)"} decision=${decision ?? "deny"} toolUseId=${toolUseId ?? "(none)"}`);
141
141
  debugLog("api", "permission-response body", toOneLineJson(req.body ?? {}));
142
142
  if (!sessionId) {
143
143
  res.status(400).json({ error: "Missing 'sessionId'" });
@@ -148,13 +148,13 @@ router.post("/permission-response", (req, res) => {
148
148
  res.status(404).json({ error: "Session not found" });
149
149
  return;
150
150
  }
151
- targetProvider.respondPermission(sessionId, decision || "deny");
151
+ targetProvider.respondPermission(sessionId, decision || "deny", toolUseId);
152
152
  res.json({ ok: true });
153
153
  });
154
154
  // POST /api/question-response
155
155
  router.post("/question-response", (req, res) => {
156
- const { sessionId, answer, provider } = req.body ?? {};
157
- console.log(`[question-response] sessionId=${sessionId ?? "(none)"} provider=${provider ?? "(default)"} answer=${String(answer ?? "skip").slice(0, 120)}`);
156
+ const { sessionId, answer, provider, toolUseId } = req.body ?? {};
157
+ console.log(`[question-response] sessionId=${sessionId ?? "(none)"} provider=${provider ?? "(default)"} answer=${String(answer ?? "skip").slice(0, 120)} toolUseId=${toolUseId ?? "(none)"}`);
158
158
  debugLog("api", "question-response body", toOneLineJson(req.body ?? {}));
159
159
  if (!sessionId) {
160
160
  res.status(400).json({ error: "Missing 'sessionId'" });
@@ -165,7 +165,7 @@ router.post("/question-response", (req, res) => {
165
165
  res.status(404).json({ error: "Session not found" });
166
166
  return;
167
167
  }
168
- targetProvider.respondQuestion(sessionId, answer || "skip");
168
+ targetProvider.respondQuestion(sessionId, answer || "skip", toolUseId);
169
169
  res.json({ ok: true });
170
170
  });
171
171
  // POST /api/interrupt
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ever-terminal",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "type": "module",
5
5
  "description": "Ever Terminal — AI Coding CLI on Smart Glasses & Flutter App",
6
6
  "license": "MIT",