niahere 0.2.33 → 0.2.34

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "niahere",
3
- "version": "0.2.33",
3
+ "version": "0.2.34",
4
4
  "description": "A personal AI assistant daemon — scheduled jobs, chat across Telegram and Slack, persona system, and visual identity.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -8,6 +8,7 @@ import { randomUUID } from "crypto";
8
8
  import { buildSystemPrompt } from "./identity";
9
9
  import { Session, Message, ActiveEngine } from "../db/models";
10
10
  import type { Attachment, SendResult, StreamCallback, ActivityCallback, SendCallbacks, ChatEngine, EngineOptions } from "../types";
11
+ import { truncate, formatToolUse } from "../utils/format-activity";
11
12
 
12
13
  const IDLE_TIMEOUT = 10 * 60 * 1000; // 10 minutes
13
14
 
@@ -97,101 +98,6 @@ interface PendingResult {
97
98
  reject: (error: Error) => void;
98
99
  }
99
100
 
100
- function truncate(s: string, max: number): string {
101
- const oneline = s.replace(/\n/g, " ").trim();
102
- return oneline.length > max ? oneline.slice(0, max) + "…" : oneline;
103
- }
104
-
105
- function formatToolUse(tool: string, input: any): string {
106
- if (!input || typeof input !== "object") return tool.toLowerCase();
107
-
108
- switch (tool) {
109
- // File operations
110
- case "Bash":
111
- return input.description
112
- ? truncate(input.description, 60)
113
- : input.command ? `$ ${truncate(input.command, 55)}` : "running command";
114
- case "Read":
115
- return input.file_path ? `reading ${basename(input.file_path)}` : "reading file";
116
- case "Edit":
117
- return input.file_path ? `editing ${basename(input.file_path)}` : "editing file";
118
- case "Write":
119
- return input.file_path ? `writing ${basename(input.file_path)}` : "writing file";
120
- case "NotebookEdit":
121
- return input.file_path ? `editing notebook ${basename(input.file_path)}` : "editing notebook";
122
-
123
- // Search operations
124
- case "Grep":
125
- return input.pattern ? `searching for "${truncate(input.pattern, 35)}"` : "searching code";
126
- case "Glob":
127
- return input.pattern ? `finding ${truncate(input.pattern, 40)}` : "finding files";
128
- case "ToolSearch":
129
- return input.query ? `looking up tool: ${truncate(input.query, 40)}` : "searching tools";
130
-
131
- // Agent & task operations
132
- case "Agent":
133
- return input.description ? `⟩ ${truncate(input.description, 55)}` : "running agent";
134
- case "Task":
135
- return input.description || input.prompt?.slice(0, 50) || "running task";
136
- case "TaskCreate":
137
- return input.description ? `starting: ${truncate(input.description, 45)}` : "creating task";
138
- case "TaskGet":
139
- case "TaskOutput":
140
- return "checking task progress";
141
- case "TaskList":
142
- return "listing tasks";
143
- case "TaskStop":
144
- return "stopping task";
145
- case "TaskUpdate":
146
- return "updating task";
147
- case "SendMessage":
148
- return input.to ? `messaging ${truncate(String(input.to), 30)}` : "sending message";
149
-
150
- // Web operations
151
- case "WebFetch":
152
- return input.url ? `fetching ${truncate(input.url, 50)}` : "fetching url";
153
- case "WebSearch":
154
- return input.query ? `web search: ${truncate(input.query, 40)}` : "searching the web";
155
-
156
- // Planning & workflow
157
- case "EnterPlanMode":
158
- return "entering plan mode";
159
- case "ExitPlanMode":
160
- return "exiting plan mode";
161
- case "EnterWorktree":
162
- return "creating worktree";
163
- case "ExitWorktree":
164
- return "leaving worktree";
165
-
166
- // Skill & todo
167
- case "Skill":
168
- return input.skill ? `using /${truncate(input.skill, 40)}` : "invoking skill";
169
- case "TodoWrite":
170
- case "TodoRead":
171
- return tool === "TodoWrite" ? "updating checklist" : "reading checklist";
172
-
173
- // LSP
174
- case "LSP":
175
- return input.command ? `lsp: ${truncate(input.command, 50)}` : "querying language server";
176
-
177
- // MCP tools (plugin_name__tool_name pattern)
178
- default: {
179
- // Handle MCP tools like mcp__playwright__browser_navigate
180
- if (tool.startsWith("mcp__")) {
181
- const parts = tool.split("__");
182
- const action = parts[parts.length - 1]?.replace(/_/g, " ") || tool;
183
- const val = input.url || input.selector || input.text || input.value || "";
184
- return val ? `${action}: ${truncate(String(val), 40)}` : action;
185
- }
186
- const val = input.description || input.command || input.pattern || input.query || input.file_path || "";
187
- return val ? `${tool.toLowerCase()}: ${truncate(String(val), 50)}` : tool.toLowerCase();
188
- }
189
- }
190
- }
191
-
192
- function basename(path: string): string {
193
- return path.split("/").pop() || path;
194
- }
195
101
 
196
102
  function sessionFileExists(sessionId: string, cwd: string): boolean {
197
103
  // SDK stores sessions at ~/.claude/projects/<encoded-cwd>/<session-id>.jsonl
package/src/cli/job.ts CHANGED
@@ -4,6 +4,7 @@ import { readState, readAudit } from "../utils/logger";
4
4
  import { getConfig } from "../utils/config";
5
5
  import { runJob } from "../core/runner";
6
6
  import { localTime } from "../utils/time";
7
+ import { formatDuration } from "../utils/format";
7
8
  import { Job } from "../db/models";
8
9
  import { withDb } from "../db/connection";
9
10
  import type { ScheduleType } from "../types";
@@ -133,6 +134,38 @@ export async function jobCommand(): Promise<void> {
133
134
  break;
134
135
  }
135
136
 
137
+ case "update": {
138
+ const always = process.argv.includes("--always");
139
+ let cliArgs = process.argv.slice(4).filter((a) => a !== "--always");
140
+
141
+ const name = cliArgs[0];
142
+ if (!name) {
143
+ console.log('Usage: nia job update <name> [--schedule <schedule>] [--prompt <prompt>] [--always]');
144
+ fail('Example: nia job update curator --schedule "4h" --prompt "New prompt"');
145
+ }
146
+
147
+ const scheduleIdx = cliArgs.indexOf("--schedule");
148
+ const schedule = scheduleIdx !== -1 ? cliArgs[scheduleIdx + 1] : undefined;
149
+ const promptIdx = cliArgs.indexOf("--prompt");
150
+ const prompt = promptIdx !== -1 ? cliArgs.slice(promptIdx + 1).filter((a) => a !== "--always" && a !== "--schedule" && a !== schedule).join(" ") : undefined;
151
+
152
+ try {
153
+ await withDb(async () => {
154
+ const fields: Partial<{ schedule: string; prompt: string; always: boolean }> = {};
155
+ if (schedule) fields.schedule = schedule;
156
+ if (prompt) fields.prompt = prompt;
157
+ if (always) fields.always = always;
158
+
159
+ const updated = await Job.update(name, fields);
160
+ if (!updated) fail(`Job not found: "${name}". Use \`nia job list\` to see available jobs.`);
161
+ console.log(`Job "${name}" updated.`);
162
+ });
163
+ } catch (err) {
164
+ fail(`Failed to update job: ${errMsg(err)}`);
165
+ }
166
+ break;
167
+ }
168
+
136
169
  case "show": {
137
170
  const name = process.argv[4] || await pickJob("Show job");
138
171
 
@@ -152,7 +185,7 @@ export async function jobCommand(): Promise<void> {
152
185
  if (info) {
153
186
  console.log(`\n last run: ${localTime(new Date(info.lastRun))}`);
154
187
  console.log(` status: ${info.status}`);
155
- console.log(` duration: ${info.duration_ms}ms`);
188
+ console.log(` duration: ${formatDuration(info.duration_ms)}`);
156
189
  if (info.error) console.log(` error: ${info.error}`);
157
190
  } else {
158
191
  console.log("\n never run");
@@ -163,7 +196,7 @@ export async function jobCommand(): Promise<void> {
163
196
  console.log("\n recent runs:");
164
197
  for (const e of entries) {
165
198
  const time = localTime(new Date(e.timestamp));
166
- const dur = `${e.duration_ms}ms`;
199
+ const dur = `${formatDuration(e.duration_ms)}`;
167
200
  const icon = e.status === "ok" ? ICON_PASS : ICON_FAIL;
168
201
  const summary = e.error || e.result.slice(0, 60).replace(/\n/g, " ") || "-";
169
202
  console.log(` ${icon} ${time} ${dur.padStart(8)} ${summary}`);
@@ -187,7 +220,7 @@ export async function jobCommand(): Promise<void> {
187
220
  const state = readState();
188
221
  const info = state[job.name];
189
222
  const status = info
190
- ? `${info.status} (${localTime(new Date(info.lastRun))}, ${info.duration_ms}ms)`
223
+ ? `${info.status} (${localTime(new Date(info.lastRun))}, ${formatDuration(info.duration_ms)})`
191
224
  : "never run";
192
225
  const tag = job.always ? " always" : "";
193
226
  console.log(` ${job.enabled ? "●" : "○"} ${job.name} [${job.schedule}]${tag} ${status}`);
@@ -211,10 +244,40 @@ export async function jobCommand(): Promise<void> {
211
244
  if (!found) fail(`Job not found: ${name}`);
212
245
  const job = found as { name: string; schedule: string; prompt: string };
213
246
 
214
- console.log(`Running job: ${job.name} (model: ${getConfig().model})`);
215
- const result = await runJob(job);
216
- console.log(`\nStatus: ${result.status}`);
217
- console.log(`Duration: ${result.duration_ms}ms`);
247
+ console.log(`Running job: ${job.name} (model: ${getConfig().model})\n`);
248
+
249
+ const MAX_LOG_LINES = 15;
250
+ const logLines: string[] = [];
251
+ let linesRendered = 0;
252
+
253
+ function renderActivity(line: string) {
254
+ logLines.push(line);
255
+ if (logLines.length > MAX_LOG_LINES) logLines.shift();
256
+
257
+ // Clear previously rendered lines
258
+ if (linesRendered > 0) {
259
+ process.stdout.write(`\x1b[${linesRendered}A\x1b[J`);
260
+ }
261
+
262
+ // Render current log lines
263
+ const output = logLines.map((l, i) => {
264
+ const dim = i < logLines.length - 1;
265
+ return dim ? ` \x1b[2m${l}\x1b[0m` : ` \x1b[36m▸\x1b[0m ${l}`;
266
+ }).join("\n");
267
+
268
+ process.stdout.write(output + "\n");
269
+ linesRendered = logLines.length;
270
+ }
271
+
272
+ const result = await runJob(job, renderActivity);
273
+
274
+ // Clear the activity log and show final result
275
+ if (linesRendered > 0) {
276
+ process.stdout.write(`\x1b[${linesRendered}A\x1b[J`);
277
+ }
278
+
279
+ console.log(`Status: ${result.status}`);
280
+ console.log(`Duration: ${formatDuration(result.duration_ms)}`);
218
281
  if (result.result) console.log(`\nResult:\n${result.result}`);
219
282
  if (result.error) console.log(`\nError: ${result.error}`);
220
283
  break;
@@ -229,7 +292,7 @@ export async function jobCommand(): Promise<void> {
229
292
  }
230
293
  for (const e of entries) {
231
294
  const time = localTime(new Date(e.timestamp));
232
- const dur = `${e.duration_ms}ms`;
295
+ const dur = `${formatDuration(e.duration_ms)}`;
233
296
  const status = e.status === "ok" ? ICON_PASS : ICON_FAIL;
234
297
  const summary = e.error || e.result.slice(0, 80).replace(/\n/g, " ") || "-";
235
298
  console.log(` ${status} ${time} ${dur.padStart(8)} ${e.job} ${summary}`);
@@ -238,12 +301,13 @@ export async function jobCommand(): Promise<void> {
238
301
  }
239
302
 
240
303
  default:
241
- console.log("Usage: nia job <list|show|status|add|remove|enable|disable|run|log|import>\n");
304
+ console.log("Usage: nia job <list|show|status|add|update|remove|enable|disable|run|log|import>\n");
242
305
  console.log(" list — list all jobs");
243
306
  console.log(" show [name] — full job details + recent runs");
244
307
  console.log(" status [name] — quick status check");
245
308
  console.log(" add <name> <schedule> <prompt> — add a job (active hours only)")
246
309
  console.log(" --always — run 24/7 regardless of active hours");
310
+ console.log(" update <name> [--schedule s] [--prompt p] [--always] — update a job");
247
311
  console.log(" remove <name> — delete a job");
248
312
  console.log(" enable <name> — enable a job");
249
313
  console.log(" disable <name> — disable a job");
package/src/cli/status.ts CHANGED
@@ -9,6 +9,7 @@ import { withDb } from "../db/connection";
9
9
  import { errMsg } from "../utils/errors";
10
10
  import { checkForUpdate } from "../utils/update";
11
11
  import { ICON_PASS, ICON_FAIL, ICON_RUNNING } from "../utils/cli";
12
+ import { formatDuration } from "../utils/format";
12
13
 
13
14
  type StatusOptions = {
14
15
  json: boolean;
@@ -247,7 +248,7 @@ export async function statusCommand(argv: string[] = []): Promise<void> {
247
248
  !stateInfo;
248
249
 
249
250
  const statusIcon = status === "ok" ? ICON_PASS : status === "error" ? ICON_FAIL : status === "running" ? ICON_RUNNING : "\u2217";
250
- const durationText = stateInfo?.duration_ms === undefined ? "n/a" : `${stateInfo.duration_ms}ms`;
251
+ const durationText = stateInfo?.duration_ms === undefined ? "n/a" : formatDuration(stateInfo.duration_ms);
251
252
  const nextText = nextRun ? formatTimeLine(nextRun, now) : "unknown";
252
253
  const lastText = lastRun ? formatTimeLine(lastRun, now) : "never";
253
254
  const staleText = stale ? " ⚠ stale" : "";
@@ -294,7 +295,7 @@ export async function statusCommand(argv: string[] = []): Promise<void> {
294
295
  for (const [name, info] of fallbackEntries) {
295
296
  const last = formatTimeLine(info.lastRun, now);
296
297
  const icon = info.status === "ok" ? ICON_PASS : info.status === "error" ? ICON_FAIL : "\u2217";
297
- console.log(` ${icon} ${name}: ${info.status} (last: ${last}, ${info.duration_ms}ms)`);
298
+ console.log(` ${icon} ${name}: ${info.status} (last: ${last}, ${formatDuration(info.duration_ms)})`);
298
299
  }
299
300
  } else if (dbError) {
300
301
  console.log(`\nJobs: database unavailable (${errMsg(dbError)})`);
@@ -7,6 +7,9 @@ import { appendAudit, readState, writeState } from "../utils/logger";
7
7
  import type { AuditEntry, JobState } from "../types";
8
8
  import { getConfig } from "../utils/config";
9
9
  import { buildSystemPrompt } from "../chat/identity";
10
+ import { truncate, formatToolUse } from "../utils/format-activity";
11
+
12
+ export type ActivityCallback = (line: string) => void;
10
13
 
11
14
  interface RunnerOutput {
12
15
  agentText: string;
@@ -65,7 +68,12 @@ async function runJobWithCodex(fullPrompt: string, cwd: string, model: string):
65
68
  // Claude Agent SDK runner
66
69
  // ---------------------------------------------------------------------------
67
70
 
68
- export async function runJobWithClaude(systemPrompt: string, jobPrompt: string, cwd: string): Promise<RunnerOutput> {
71
+ export async function runJobWithClaude(
72
+ systemPrompt: string,
73
+ jobPrompt: string,
74
+ cwd: string,
75
+ onActivity?: ActivityCallback,
76
+ ): Promise<RunnerOutput> {
69
77
  const sessionId = randomUUID();
70
78
 
71
79
  // One-shot async iterable: emit a single user message then close
@@ -90,12 +98,69 @@ export async function runJobWithClaude(systemPrompt: string, jobPrompt: string,
90
98
 
91
99
  let agentText = "";
92
100
  let actualSessionId = sessionId;
101
+ let accumulatedThinking = "";
102
+ let lastThinkingLine = "";
93
103
 
94
104
  try {
95
105
  for await (const message of handle) {
96
106
  if (message.type === "system" && (message as any).subtype === "init") {
97
107
  actualSessionId = (message as any).session_id || sessionId;
98
108
  }
109
+
110
+ // Stream activity events
111
+ if (onActivity) {
112
+ const msg = message as any;
113
+
114
+ if (message.type === "stream_event") {
115
+ const event = msg.event;
116
+ if (event?.type === "content_block_start" && event.content_block?.type === "thinking") {
117
+ accumulatedThinking = "";
118
+ lastThinkingLine = "";
119
+ onActivity("thinking...");
120
+ }
121
+ if (event?.type === "content_block_delta") {
122
+ const delta = event.delta;
123
+ if (delta?.type === "thinking_delta" && delta.thinking) {
124
+ accumulatedThinking += delta.thinking;
125
+ const lines = accumulatedThinking.split("\n");
126
+ if (lines.length > 1) {
127
+ const completeLine = lines[lines.length - 2]?.trim();
128
+ if (completeLine && completeLine !== lastThinkingLine) {
129
+ lastThinkingLine = completeLine;
130
+ onActivity(truncate(completeLine, 70));
131
+ }
132
+ }
133
+ }
134
+ }
135
+ if (event?.type === "content_block_stop") {
136
+ accumulatedThinking = "";
137
+ lastThinkingLine = "";
138
+ }
139
+ }
140
+
141
+ if (message.type === "tool_use_summary") {
142
+ const name = msg.tool_name || "tool";
143
+ onActivity(formatToolUse(name, msg.tool_input));
144
+ }
145
+
146
+ if (message.type === "tool_progress") {
147
+ if (msg.tool_name === "Bash" && msg.content) {
148
+ onActivity(`$ ${truncate(msg.content, 60)}`);
149
+ } else if (msg.content) {
150
+ onActivity(truncate(msg.content, 70));
151
+ }
152
+ }
153
+
154
+ if (message.type === "system") {
155
+ if (msg.subtype === "task_started" && msg.description) {
156
+ onActivity(truncate(msg.description, 60));
157
+ }
158
+ if (msg.subtype === "task_progress" && msg.last_tool_name) {
159
+ onActivity(msg.summary || msg.last_tool_name);
160
+ }
161
+ }
162
+ }
163
+
99
164
  if (message.type === "result") {
100
165
  if (!(message as any).is_error) {
101
166
  agentText = (message as any).result || "";
@@ -116,7 +181,7 @@ export async function runJobWithClaude(systemPrompt: string, jobPrompt: string,
116
181
  // Public API
117
182
  // ---------------------------------------------------------------------------
118
183
 
119
- export async function runJob(job: JobInput): Promise<JobResult> {
184
+ export async function runJob(job: JobInput, onActivity?: ActivityCallback): Promise<JobResult> {
120
185
  const config = getConfig();
121
186
  const timestamp = new Date().toISOString();
122
187
  const startMs = performance.now();
@@ -136,7 +201,7 @@ export async function runJob(job: JobInput): Promise<JobResult> {
136
201
  } else {
137
202
  const systemPrompt = buildSystemPrompt("job");
138
203
  const jobPrompt = `Job: ${job.name} (schedule: ${job.schedule})\n\n${job.prompt}`;
139
- output = await runJobWithClaude(systemPrompt, jobPrompt, cwd);
204
+ output = await runJobWithClaude(systemPrompt, jobPrompt, cwd, onActivity);
140
205
  }
141
206
 
142
207
  const duration_ms = Math.round(performance.now() - startMs);
@@ -42,6 +42,10 @@ export async function create(
42
42
  scheduleType: ScheduleType = "cron",
43
43
  nextRunAt?: Date,
44
44
  ): Promise<void> {
45
+ const existing = await get(name);
46
+ if (existing) {
47
+ throw new Error(`Job "${name}" already exists. Use \`nia job remove ${name}\` first, or choose a different name.`);
48
+ }
45
49
  const sql = getSql();
46
50
  await sql`
47
51
  INSERT INTO jobs (name, schedule, prompt, always, schedule_type, next_run_at)
package/src/mcp/server.ts CHANGED
@@ -29,6 +29,19 @@ export function createNiaMcpServer() {
29
29
  content: [{ type: "text" as const, text: await handlers.addJob(args) }],
30
30
  }),
31
31
  ),
32
+ tool(
33
+ "update_job",
34
+ "Update an existing job's schedule, prompt, or always flag. Only pass fields you want to change.",
35
+ {
36
+ name: z.string().describe("Job name to update"),
37
+ schedule: z.string().optional().describe("New schedule (cron expression, interval duration, or ISO timestamp)"),
38
+ prompt: z.string().optional().describe("New prompt"),
39
+ always: z.boolean().optional().describe("If true, runs 24/7 ignoring active hours"),
40
+ },
41
+ async (args) => ({
42
+ content: [{ type: "text" as const, text: await handlers.updateJob(args) }],
43
+ }),
44
+ ),
32
45
  tool(
33
46
  "remove_job",
34
47
  "Delete a scheduled job",
package/src/mcp/tools.ts CHANGED
@@ -31,6 +31,24 @@ export async function addJob(args: {
31
31
  return `Job "${args.name}" created (${scheduleType}: ${args.schedule}). Next run: ${nextRunAt.toISOString()}`;
32
32
  }
33
33
 
34
+ export async function updateJob(args: {
35
+ name: string;
36
+ schedule?: string;
37
+ prompt?: string;
38
+ always?: boolean;
39
+ }): Promise<string> {
40
+ const fields: Partial<{ schedule: string; prompt: string; always: boolean }> = {};
41
+ if (args.schedule) fields.schedule = args.schedule;
42
+ if (args.prompt) fields.prompt = args.prompt;
43
+ if (args.always !== undefined) fields.always = args.always;
44
+
45
+ if (Object.keys(fields).length === 0) return "Nothing to update. Pass at least one field (schedule, prompt, or always).";
46
+
47
+ const updated = await Job.update(args.name, fields);
48
+ if (!updated) return `Job "${args.name}" not found.`;
49
+ return `Job "${args.name}" updated.`;
50
+ }
51
+
34
52
  export async function removeJob(name: string): Promise<string> {
35
53
  const removed = await Job.remove(name);
36
54
  return removed ? `Job "${name}" removed.` : `Job "${name}" not found.`;
@@ -25,6 +25,7 @@ You have MCP tools for managing jobs directly (preferred over CLI for speed):
25
25
  - `interval`: duration string (e.g., "5m", "2h", "1d" = every 5 min/2 hours/1 day)
26
26
  - `once`: ISO timestamp for one-time execution (e.g., "2026-03-14T10:00:00")
27
27
  - Set `always: true` to run 24/7 (ignores active hours)
28
+ - **update_job** — update an existing job's schedule, prompt, or always flag
28
29
  - **remove_job** — delete a job by name
29
30
  - **enable_job** / **disable_job** — toggle a job on or off
30
31
  - **run_job** — trigger a job to run immediately
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Shared formatting utilities for SDK activity messages.
3
+ * Used by both the chat engine and the job runner for live activity display.
4
+ */
5
+
6
+ export function truncate(s: string, max: number): string {
7
+ const oneline = s.replace(/\n/g, " ").trim();
8
+ return oneline.length > max ? oneline.slice(0, max) + "…" : oneline;
9
+ }
10
+
11
+ function basename(path: string): string {
12
+ return path.split("/").pop() || path;
13
+ }
14
+
15
+ export function formatToolUse(tool: string, input: any): string {
16
+ if (!input || typeof input !== "object") return tool.toLowerCase();
17
+
18
+ switch (tool) {
19
+ // File operations
20
+ case "Bash":
21
+ return input.description
22
+ ? truncate(input.description, 60)
23
+ : input.command ? `$ ${truncate(input.command, 55)}` : "running command";
24
+ case "Read":
25
+ return input.file_path ? `reading ${basename(input.file_path)}` : "reading file";
26
+ case "Edit":
27
+ return input.file_path ? `editing ${basename(input.file_path)}` : "editing file";
28
+ case "Write":
29
+ return input.file_path ? `writing ${basename(input.file_path)}` : "writing file";
30
+ case "NotebookEdit":
31
+ return input.file_path ? `editing notebook ${basename(input.file_path)}` : "editing notebook";
32
+
33
+ // Search operations
34
+ case "Grep":
35
+ return input.pattern ? `searching for "${truncate(input.pattern, 35)}"` : "searching code";
36
+ case "Glob":
37
+ return input.pattern ? `finding ${truncate(input.pattern, 40)}` : "finding files";
38
+ case "ToolSearch":
39
+ return input.query ? `looking up tool: ${truncate(input.query, 40)}` : "searching tools";
40
+
41
+ // Agent & task operations
42
+ case "Agent":
43
+ return input.description ? `⟩ ${truncate(input.description, 55)}` : "running agent";
44
+ case "Task":
45
+ return input.description || input.prompt?.slice(0, 50) || "running task";
46
+ case "TaskCreate":
47
+ return input.description ? `starting: ${truncate(input.description, 45)}` : "creating task";
48
+ case "TaskGet":
49
+ case "TaskOutput":
50
+ return "checking task progress";
51
+ case "TaskList":
52
+ return "listing tasks";
53
+ case "TaskStop":
54
+ return "stopping task";
55
+ case "TaskUpdate":
56
+ return "updating task";
57
+ case "SendMessage":
58
+ return input.to ? `messaging ${truncate(String(input.to), 30)}` : "sending message";
59
+
60
+ // Web operations
61
+ case "WebFetch":
62
+ return input.url ? `fetching ${truncate(input.url, 50)}` : "fetching url";
63
+ case "WebSearch":
64
+ return input.query ? `web search: ${truncate(input.query, 40)}` : "searching the web";
65
+
66
+ // Planning & workflow
67
+ case "EnterPlanMode":
68
+ return "entering plan mode";
69
+ case "ExitPlanMode":
70
+ return "exiting plan mode";
71
+ case "EnterWorktree":
72
+ return "creating worktree";
73
+ case "ExitWorktree":
74
+ return "leaving worktree";
75
+
76
+ // Skill & todo
77
+ case "Skill":
78
+ return input.skill ? `using /${truncate(input.skill, 40)}` : "invoking skill";
79
+ case "TodoWrite":
80
+ case "TodoRead":
81
+ return tool === "TodoWrite" ? "updating checklist" : "reading checklist";
82
+
83
+ // LSP
84
+ case "LSP":
85
+ return input.command ? `lsp: ${truncate(input.command, 50)}` : "querying language server";
86
+
87
+ // MCP tools (plugin_name__tool_name pattern)
88
+ default: {
89
+ if (tool.startsWith("mcp__")) {
90
+ const parts = tool.split("__");
91
+ const action = parts[parts.length - 1]?.replace(/_/g, " ") || tool;
92
+ const val = input.url || input.selector || input.text || input.value || "";
93
+ return val ? `${action}: ${truncate(String(val), 40)}` : action;
94
+ }
95
+ const val = input.description || input.command || input.pattern || input.query || input.file_path || "";
96
+ return val ? `${tool.toLowerCase()}: ${truncate(String(val), 50)}` : tool.toLowerCase();
97
+ }
98
+ }
99
+ }
@@ -40,6 +40,18 @@ export function relativeTime(date: Date, now = new Date()): string {
40
40
  return deltaMs > 0 ? `in ${text}` : `${text} ago`;
41
41
  }
42
42
 
43
+ export function formatDuration(ms: number): string {
44
+ if (ms < 1000) return `${ms}ms`;
45
+ const totalSeconds = Math.round(ms / 1000);
46
+ if (totalSeconds < 60) return `${totalSeconds}s`;
47
+ const minutes = Math.floor(totalSeconds / 60);
48
+ const seconds = totalSeconds % 60;
49
+ if (minutes < 60) return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
50
+ const hours = Math.floor(minutes / 60);
51
+ const remainingMinutes = minutes % 60;
52
+ return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
53
+ }
54
+
43
55
  export function formatTimeLine(date: string | null | undefined, now = new Date()): string {
44
56
  const parsed = safeDate(date);
45
57
  if (!parsed) return "unknown";