s-app-scoder-agent 0.8.20 → 0.9.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.
package/dist/tui/state.js CHANGED
@@ -20,6 +20,14 @@ export function initialState() {
20
20
  phases: [],
21
21
  mcpServers: {},
22
22
  streamingOutputChars: 0,
23
+ streamStartedAt: null,
24
+ llmCallStartedAt: null,
25
+ lastLlmTokPerSec: null,
26
+ agent: "build",
27
+ context: null,
28
+ queue: { pending: 0, steering: 0 },
29
+ verification: null,
30
+ subagents: [],
23
31
  };
24
32
  }
25
33
  const MAX_ITEMS = 400;
@@ -41,6 +49,8 @@ export function estimateItemRows(item) {
41
49
  return 1 + Math.ceil((item.text?.length ?? 0) / 80) + (item.text ? item.text.split("\n").length : 0);
42
50
  case "tool":
43
51
  return 1 + 2; // title + preview 2 lines
52
+ case "compaction":
53
+ return 1;
44
54
  case "diff":
45
55
  return 3 + Math.min(24, (item.text?.split("\n").length ?? 0)); // header + ≤24 lines + border
46
56
  case "todo":
@@ -111,12 +121,18 @@ function capTail(text, max = MAX_OUTPUT_CHARS) {
111
121
  export function toolPhase(name) {
112
122
  if (name === "write_todos")
113
123
  return "plan";
114
- if (name === "list_files" || name === "read_file" || name === "web_fetch" || name === "load_skill")
124
+ if (name === "list_files" || name === "read_file" || name === "grep" || name === "glob" || name === "web_fetch" || name === "load_skill" || name === "repo_map")
115
125
  return "explore";
116
- if (name === "write_file" || name === "edit_file")
126
+ if (name === "write_file" || name === "edit_file" || name === "apply_patch")
117
127
  return "edit";
118
128
  if (name === "run_command")
119
129
  return "build";
130
+ if (name === "task")
131
+ return "delegate";
132
+ if (name === "ask_user")
133
+ return "clear";
134
+ if (name.startsWith("mcp__"))
135
+ return "mcp";
120
136
  return null;
121
137
  }
122
138
  /** Guardrail event: đếm + giữ decision gần nhất (ngắn, tự giải thích cho header). */
@@ -156,6 +172,11 @@ export function applyEvent(state, ev) {
156
172
  state.buildCmd = ev.buildCmd;
157
173
  state.buildLamp = "none";
158
174
  state.permission = ev.permission;
175
+ state.agent = ev.agent ?? "build";
176
+ state.context = null;
177
+ state.queue = { pending: 0, steering: 0 };
178
+ state.verification = null;
179
+ state.subagents = [];
159
180
  state.startedAt = Date.now();
160
181
  state.endedAt = null;
161
182
  state.status = "running";
@@ -167,6 +188,9 @@ export function applyEvent(state, ev) {
167
188
  state.lastGuardrail = null;
168
189
  state.phases = [];
169
190
  state.streamingOutputChars = 0;
191
+ state.streamStartedAt = null;
192
+ state.llmCallStartedAt = null;
193
+ state.lastLlmTokPerSec = null;
170
194
  // The TUI pushes the user's message itself when submitting; avoid a
171
195
  // duplicate when run_start carries the same task.
172
196
  const last = lastItem(state);
@@ -194,6 +218,10 @@ export function applyEvent(state, ev) {
194
218
  case "step": {
195
219
  state.step = ev.step;
196
220
  state.maxSteps = ev.maxSteps;
221
+ // New LLM turn: reset per-stream tracking.
222
+ state.streamingOutputChars = 0;
223
+ state.streamStartedAt = null;
224
+ state.llmCallStartedAt = Date.now();
197
225
  // A new step starts: close any still-"streaming" assistant message.
198
226
  const last = lastItem(state);
199
227
  if (last && last.kind === "assistant" && last.streaming)
@@ -203,6 +231,9 @@ export function applyEvent(state, ev) {
203
231
  break;
204
232
  }
205
233
  case "llm_delta": {
234
+ if (state.streamStartedAt == null) {
235
+ state.streamStartedAt = state.llmCallStartedAt ?? Date.now();
236
+ }
206
237
  state.streamingOutputChars += ev.delta.length;
207
238
  const last = lastItem(state);
208
239
  if (last && last.kind === "assistant" && last.streaming) {
@@ -226,6 +257,17 @@ export function applyEvent(state, ev) {
226
257
  state.usage.inputTokens += ev.usage.input_tokens ?? 0;
227
258
  state.usage.outputTokens += ev.usage.output_tokens ?? 0;
228
259
  state.usage.cachedTokens += ev.usage.cached_tokens ?? 0;
260
+ // Per-call speed from the actual call duration, not the whole run.
261
+ const start = state.streamStartedAt ?? state.llmCallStartedAt;
262
+ if (start != null) {
263
+ const elapsedMs = Math.max(1, Date.now() - start);
264
+ const out = ev.usage.output_tokens ?? 0;
265
+ if (out > 0) {
266
+ state.lastLlmTokPerSec = Math.round(out / (elapsedMs / 1000));
267
+ }
268
+ }
269
+ state.streamingOutputChars = 0;
270
+ state.streamStartedAt = null;
229
271
  break;
230
272
  }
231
273
  case "tool_call": {
@@ -320,10 +362,67 @@ export function applyEvent(state, ev) {
320
362
  appendItem(state, { kind: "notice", text: ev.line, level: ev.line.includes("unavailable") ? "warn" : "info" });
321
363
  break;
322
364
  }
365
+ case "steer_queued": {
366
+ // Pending depth tracked via the absolute `queue` event; nothing else.
367
+ state.queue = { ...state.queue, pending: state.queue.pending + 1, steering: state.queue.steering + (ev.mode === "steer" ? 1 : 0) };
368
+ break;
369
+ }
370
+ case "steer_delivered": {
371
+ state.queue = { pending: 0, steering: 0 };
372
+ appendItem(state, { kind: "notice", text: `steer: ${ev.count} message(s) delivered`, level: "info" });
373
+ break;
374
+ }
375
+ case "queue": {
376
+ state.queue = { pending: ev.pending, steering: ev.steering };
377
+ break;
378
+ }
379
+ case "compaction": {
380
+ appendItem(state, { kind: "compaction", tokensBefore: ev.tokensBefore, tokensAfter: ev.tokensAfter, manual: ev.manual });
381
+ break;
382
+ }
383
+ case "context": {
384
+ state.context = { tokens: ev.tokens, window: ev.window, pct: ev.pct };
385
+ break;
386
+ }
387
+ case "agent_start": {
388
+ // Track in the tree list; rendering lands in the sidebar (Phase 7).
389
+ const rest = state.subagents.filter((s) => s.id !== ev.id);
390
+ rest.push({ id: ev.id, name: ev.name, description: ev.description, status: "running" });
391
+ state.subagents = rest.slice(-20);
392
+ break;
393
+ }
394
+ case "agent_end": {
395
+ state.subagents = state.subagents.map((s) => s.id === ev.id ? { ...s, status: ev.ok ? "ok" : "failed", steps: ev.steps, summary: ev.summary } : s);
396
+ break;
397
+ }
398
+ case "tool_progress": {
399
+ // Attach latest progress message to the running tool card (if any).
400
+ for (let i = state.items.length - 1; i >= 0; i--) {
401
+ const it = state.items[i];
402
+ if (it.kind === "tool" && it.ok === null && (it.callId === ev.id || it.name === ev.name)) {
403
+ if (ev.message)
404
+ it.output = capTail(`${it.output}\n… ${ev.message}`.trim());
405
+ break;
406
+ }
407
+ if (it.kind === "tool")
408
+ break;
409
+ }
410
+ break;
411
+ }
412
+ case "question_request": {
413
+ // Surfaced via the dedicated ask_user modal (Phase 3/7); queue a notice
414
+ // so the transcript isn't silent while waiting for user input.
415
+ appendItem(state, { kind: "notice", text: `❓ ${ev.question}`, level: "info" });
416
+ break;
417
+ }
323
418
  case "finish": {
324
419
  state.status = ev.success ? "success" : /interrupted|aborted/i.test(ev.summary) ? "aborted" : "failed";
325
420
  state.summary = ev.summary;
326
421
  state.endedAt = Date.now();
422
+ if (ev.usage)
423
+ state.usage = { ...ev.usage };
424
+ if (ev.verification)
425
+ state.verification = { ...ev.verification };
327
426
  // Close ALL dangling streaming assistant messages — not just the last
328
427
  // item: a notice/tool item appended after the assistant text would
329
428
  // otherwise leave it stuck in raw streaming mode (invisible text).
package/dist/tui/theme.js CHANGED
@@ -37,6 +37,10 @@ export const THEMES = {
37
37
  light: LIGHT, "high-contrast": HIGH_CONTRAST, solarized: SOLARIZED,
38
38
  };
39
39
  export function defaultThemeName(env = process.env) {
40
+ if (env.SCODER_THEME && THEMES[env.SCODER_THEME])
41
+ return env.SCODER_THEME;
42
+ if (env.SCODER_PLAIN === "1" || env.NO_COLOR === "1" || process.env.NO_COLOR === "1")
43
+ return "mono";
40
44
  if (env.TERM === "dumb" || !env.TERM)
41
45
  return "mono";
42
46
  if (env.COLOR_COUNT && Number(env.COLOR_COUNT) < 16)
@@ -0,0 +1,145 @@
1
+ // ── Export ────────────────────────────────────────────────────────────────────
2
+ /** Render a ChatItem to its plain-text representation for export. */
3
+ export function itemToText(item) {
4
+ switch (item.kind) {
5
+ case "user": return `## you\n\n${item.text}`;
6
+ case "assistant": return `## agent\n\n${item.text}`;
7
+ case "thinking": return `## thinking\n\n${item.text}`;
8
+ case "tool": {
9
+ const args = Object.entries(item.args).map(([k, v]) => ` ${k}=${JSON.stringify(v)}`).join("\n");
10
+ const elapsed = item.durationMs != null ? ` (${item.durationMs}ms)` : "";
11
+ const status = item.ok === null ? "running" : item.ok ? "ok" : "FAILED";
12
+ return `## tool: ${item.name}${elapsed}\n\n${status}${args ? "\n" + args : ""}\n\n${item.output}`;
13
+ }
14
+ case "diff":
15
+ return `## diff: ${item.path} (+${item.added}/-${item.removed})\n\n${item.text}`;
16
+ case "build":
17
+ return `## build: ${item.command}\n\n${item.success ? "✓ passed" : "✗ FAILED"}`;
18
+ case "todo": {
19
+ const rows = item.items.map((t) => `- [${t.status === "completed" ? "x" : t.status === "in_progress" ? ">" : " "}] ${t.content}`);
20
+ return `## plan\n\n${rows.join("\n")}`;
21
+ }
22
+ case "compaction":
23
+ return `## compacted (${item.tokensBefore} → ${item.tokensAfter} tokens)${item.manual ? " [manual]" : ""}`;
24
+ case "notice":
25
+ return `${item.level === "error" ? "✗" : item.level === "warn" ? "⚠" : ""} ${item.text}`;
26
+ }
27
+ }
28
+ /** Format a chat item as Markdown (GitHub-flavored). */
29
+ export function itemToMarkdown(item) {
30
+ const ts = item.kind === "tool" ? `[${item.step.toString().padStart(2, "0")}]` : "";
31
+ switch (item.kind) {
32
+ case "user":
33
+ return `## you\n\n> ${item.text}`;
34
+ case "assistant":
35
+ return `## agent\n\n${item.text}`;
36
+ case "thinking":
37
+ return `> ⊙ ${item.text.split("\n").slice(-4).join(" | ")}`;
38
+ case "tool": {
39
+ const elapsed = item.durationMs != null ? ` — ${(item.durationMs / 1000).toFixed(1)}s` : "";
40
+ const ok = item.ok === null ? "⏳ running" : item.ok ? "✅" : "❌";
41
+ const args = Object.keys(item.args).length > 0
42
+ ? `\`${item.name}(${Object.entries(item.args).map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(", ")})\``
43
+ : `\`${item.name}\``;
44
+ return `${ts} ${ok} **${args}**${elapsed}\n\n\`\`\`\n${item.output}\n\`\`\``;
45
+ }
46
+ case "diff":
47
+ return `${ts} ✎ \`${item.path}\` \`+${item.added}/-${item.removed}\`\n\`\`\`diff\n${item.text}\n\`\`\``;
48
+ case "build":
49
+ return `${item.success ? "✅" : "❌"} **build**: \`${item.command}\``;
50
+ case "todo": {
51
+ const rows = item.items.map((t) => `- [${t.status === "completed" ? "x" : t.status === "in_progress" ? ">" : " "}] ${t.content}`);
52
+ return `## plan\n\n${rows.join("\n")}`;
53
+ }
54
+ case "compaction":
55
+ return `\n---\n*compacted: ${item.tokensBefore} → ${item.tokensAfter} tokens*${item.manual ? " (manual)" : ""}\n---\n`;
56
+ case "notice":
57
+ return `${item.level === "error" ? "> ❌" : item.level === "warn" ? "> ⚠" : ">"} ${item.text}`;
58
+ }
59
+ }
60
+ /** Export transcript to the requested format. */
61
+ export function exportTranscript(items, format = "md") {
62
+ if (format === "json") {
63
+ return JSON.stringify(items, null, 2);
64
+ }
65
+ return items.map(itemToMarkdown).join("\n\n");
66
+ }
67
+ /** Case-insensitive search over transcript items.
68
+ * Returns all items whose text representation matches `query`.
69
+ * Searches: assistant/thinking text, tool name+output, diff path+text, notice text.
70
+ */
71
+ export function findInTranscript(items, query) {
72
+ if (!query.trim())
73
+ return [];
74
+ const lc = query.toLowerCase();
75
+ const results = [];
76
+ for (let i = 0; i < items.length; i++) {
77
+ const item = items[i];
78
+ const matched = (text) => text.toLowerCase().includes(lc);
79
+ let hit = false;
80
+ let snippet = "";
81
+ switch (item.kind) {
82
+ case "assistant":
83
+ case "thinking":
84
+ if (matched(item.text)) {
85
+ hit = true;
86
+ snippet = snippetOf(item.text, query);
87
+ }
88
+ break;
89
+ case "tool":
90
+ if (matched(item.name) || matched(item.output) || matched(JSON.stringify(item.args))) {
91
+ hit = true;
92
+ snippet = snippetOf(`${item.name}: ${item.output}`, query);
93
+ }
94
+ break;
95
+ case "diff":
96
+ if (matched(item.path) || matched(item.text)) {
97
+ hit = true;
98
+ snippet = snippetOf(`${item.path} · ${item.text}`, query);
99
+ }
100
+ break;
101
+ case "user":
102
+ if (matched(item.text)) {
103
+ hit = true;
104
+ snippet = snippetOf(item.text, query);
105
+ }
106
+ break;
107
+ case "notice":
108
+ if (matched(item.text)) {
109
+ hit = true;
110
+ snippet = snippetOf(item.text, query);
111
+ }
112
+ break;
113
+ case "build":
114
+ if (matched(item.command)) {
115
+ hit = true;
116
+ snippet = snippetOf(item.command, query);
117
+ }
118
+ break;
119
+ default:
120
+ break;
121
+ }
122
+ if (hit)
123
+ results.push({ index: i, kind: item.kind, snippet });
124
+ }
125
+ return results;
126
+ }
127
+ /** Return a ~120-char snippet around the first occurrence of `query` in `text`. */
128
+ function snippetOf(text, query) {
129
+ const pos = text.toLowerCase().indexOf(query.toLowerCase());
130
+ if (pos === -1)
131
+ return text.slice(0, 120);
132
+ const start = Math.max(0, pos - 40);
133
+ const end = Math.min(text.length, pos + query.length + 80);
134
+ return (start > 0 ? "…" : "") + text.slice(start, end) + (end < text.length ? "…" : "");
135
+ }
136
+ // ── Copy ─────────────────────────────────────────────────────────────────────
137
+ /** Return the copyable text for a chat item (for clipboard). */
138
+ export function copyTextForItem(item) {
139
+ switch (item.kind) {
140
+ case "diff": return item.text;
141
+ case "tool": return item.output;
142
+ case "assistant": return item.text;
143
+ default: return itemToText(item);
144
+ }
145
+ }
package/dist/watch.js CHANGED
@@ -29,6 +29,14 @@ export function watchWorkdir(workdir, onChange) {
29
29
  }
30
30
  catch { /* race — file removed between existsSync and watch */ }
31
31
  }
32
+ // Watch the parent too so a skills directory created after startup is seen.
33
+ const scoderDir = join(workdir, ".scoder");
34
+ if (existsSync(scoderDir)) {
35
+ try {
36
+ watchers.push(watch(scoderDir, () => fire("skills")));
37
+ }
38
+ catch { /* ignore */ }
39
+ }
32
40
  // .scoder/skills/ — skill files added/removed/edited.
33
41
  const skillsDir = join(workdir, ".scoder", "skills");
34
42
  if (existsSync(skillsDir)) {
package/dist/webfetch.js CHANGED
@@ -39,8 +39,8 @@ function isPrivateAddress(ip) {
39
39
  const lower = ip.toLowerCase();
40
40
  if (lower === "::1" || lower === "::")
41
41
  return true;
42
- if (lower.startsWith("fe80"))
43
- return true; // link-local
42
+ if (/^fe[89ab][0-9a-f]{2}:/.test(lower))
43
+ return true; // link-local fe80::/10
44
44
  if (/^f[cd][0-9a-f]{2}:/.test(lower))
45
45
  return true; // unique local fc00::/7
46
46
  // IPv4-mapped ::ffff:10.0.0.1
@@ -166,6 +166,9 @@ export async function toolWebFetch(ctx, args) {
166
166
  if (signal.aborted) {
167
167
  return { ok: false, output: `ERROR: fetch aborted (${signal.reason?.message ?? "aborted"})` };
168
168
  }
169
+ if (current.protocol !== "http:" && current.protocol !== "https:") {
170
+ return { ok: false, output: `ERROR: redirect target uses unsupported protocol ${current.protocol}` };
171
+ }
169
172
  await assertPublicHost(current.hostname);
170
173
  const res = await fetch(current, {
171
174
  method: "GET",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "s-app-scoder-agent",
3
- "version": "0.8.20",
3
+ "version": "0.9.2",
4
4
  "description": "Autonomous coding agent CLI — writes, edits, and builds code autonomously until the project compiles without errors.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",