takomi 2.1.28 → 2.1.30

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,4 +1,4 @@
1
- import type { Api, AssistantMessage, Context, Model, SimpleStreamOptions } from "@mariozechner/pi-ai";
1
+ import type { Api, AssistantMessage, Context, Model, SimpleStreamOptions } from "@earendil-works/pi-ai";
2
2
 
3
3
  export type RouterUpstreamApi = "openai-completions" | "openai-responses" | "openai-codex-responses";
4
4
  export type RouterAuthMode = "oauth" | "api-key";
@@ -27,6 +27,8 @@ export interface RouterModelConfig {
27
27
  export interface RouterProviderUsageProbeConfig {
28
28
  enabled?: boolean;
29
29
  endpoints?: string[];
30
+ /** Total best-effort probe budget per account. Provider usage checks must not block login/refresh UX. */
31
+ timeoutMs?: number;
30
32
  }
31
33
 
32
34
  export interface RouterUpstreamConfig {
@@ -50,6 +52,18 @@ export interface RouterConfig {
50
52
  tokenRefreshSkewMs: number;
51
53
  rateLimitCooldownMs: number;
52
54
  transientPenaltyMs: number;
55
+ /** Default retry attempts passed to upstream providers when the caller does not specify one. */
56
+ upstreamMaxRetries: number;
57
+ /** Maximum provider-request retry delay when supported by the upstream provider. */
58
+ upstreamMaxRetryDelayMs: number;
59
+ /** Router-level same-account retries for local/client transport failures before account failover. */
60
+ clientNetworkMaxRetries: number;
61
+ /** First router-level retry delay for local/client transport failures. Later retries double this delay. */
62
+ clientNetworkRetryBaseDelayMs: number;
63
+ /** Maximum router-level retry delay for local/client transport failures. */
64
+ clientNetworkMaxRetryDelayMs: number;
65
+ /** Penalty for local/client transport failures. Zero records the failure without cooling down the account. */
66
+ clientNetworkPenaltyMs: number;
53
67
  models: RouterModelConfig[];
54
68
  upstreams: RouterUpstreamConfig[];
55
69
  }
@@ -178,6 +192,7 @@ export interface RouterStatusRow {
178
192
  rateLimitCount: number;
179
193
  authFailureCount: number;
180
194
  successCount: number;
195
+ lastError?: string;
181
196
  expires: number;
182
197
  }
183
198
 
@@ -198,7 +213,7 @@ export interface DelegateSelection {
198
213
  }
199
214
 
200
215
  export interface FailureClassification {
201
- kind: "rate-limit" | "auth" | "transient" | "fatal";
216
+ kind: "rate-limit" | "auth" | "transient" | "client-network" | "fatal";
202
217
  status?: number;
203
218
  retryAfterMs?: number;
204
219
  message: string;
@@ -53,6 +53,8 @@ export type ContextPanelState = {
53
53
  toolUses: ToolUseCount;
54
54
  sessionStart: number;
55
55
  activeMs: number;
56
+ activityIntervals: ActivityInterval[];
57
+ pendingToolStarts: Record<string, number>;
56
58
  lastActivityAt: number;
57
59
  lastToolAt: number;
58
60
  runtime: ContextRuntimeState;
@@ -70,12 +72,28 @@ type BoardCounts = {
70
72
  blocked: number;
71
73
  };
72
74
 
75
+ type BoardCountsCacheEntry = {
76
+ checkedAt: number;
77
+ mtimeMs: number;
78
+ counts?: BoardCounts;
79
+ };
80
+
81
+ type ActivityInterval = {
82
+ start: number;
83
+ end: number;
84
+ };
85
+
86
+ const BOARD_COUNTS_CACHE_MS = 1_000;
87
+ const boardCountsCache = new Map<string, BoardCountsCacheEntry>();
88
+
73
89
  function createEmptyState(sessionStart = Date.now(), runtime: ContextRuntimeState = {}): ContextPanelState {
74
90
  return {
75
91
  fileChanges: [],
76
92
  toolUses: {},
77
93
  sessionStart,
78
94
  activeMs: 0,
95
+ activityIntervals: [],
96
+ pendingToolStarts: {},
79
97
  lastActivityAt: sessionStart,
80
98
  lastToolAt: 0,
81
99
  runtime,
@@ -164,14 +182,28 @@ function toTimestampMs(entry: { timestamp?: unknown; message?: { timestamp?: unk
164
182
  return undefined;
165
183
  }
166
184
 
167
- function calculateActiveMs(timestamps: number[]): number {
168
- const sorted = [...new Set(timestamps.filter((value) => Number.isFinite(value)))].sort((a, b) => a - b);
169
- let activeMs = 0;
185
+ function mergeIntervals(intervals: ActivityInterval[]): ActivityInterval[] {
186
+ const sorted = intervals
187
+ .filter((interval) => Number.isFinite(interval.start) && Number.isFinite(interval.end) && interval.end > interval.start)
188
+ .map((interval) => ({ start: interval.start, end: interval.end }))
189
+ .sort((a, b) => a.start - b.start || a.end - b.end);
190
+ if (sorted.length === 0) return [];
191
+
192
+ const merged: ActivityInterval[] = [sorted[0]];
170
193
  for (let i = 1; i < sorted.length; i += 1) {
171
- const delta = sorted[i] - sorted[i - 1];
172
- if (delta > 0 && delta <= ACTIVE_GAP_THRESHOLD_MS) activeMs += delta;
194
+ const current = sorted[i];
195
+ const previous = merged[merged.length - 1];
196
+ if (current.start <= previous.end) {
197
+ previous.end = Math.max(previous.end, current.end);
198
+ } else {
199
+ merged.push({ ...current });
200
+ }
173
201
  }
174
- return activeMs;
202
+ return merged;
203
+ }
204
+
205
+ function calculateActiveMs(intervals: ActivityInterval[]): number {
206
+ return mergeIntervals(intervals).reduce((total, interval) => total + (interval.end - interval.start), 0);
175
207
  }
176
208
 
177
209
  function formatDisplayPath(filePath: string, cwd?: string): string {
@@ -186,8 +218,19 @@ function formatDisplayPath(filePath: string, cwd?: string): string {
186
218
 
187
219
  function loadBoardCounts(cwd: string, sessionId?: string): BoardCounts | undefined {
188
220
  if (!sessionId) return undefined;
221
+ const stateFile = path.join(cwd, ".pi", "takomi", "orchestrator", `${sessionId}.json`);
222
+ const cached = boardCountsCache.get(stateFile);
223
+ const checkedAt = Date.now();
224
+
225
+ if (cached && checkedAt - cached.checkedAt < BOARD_COUNTS_CACHE_MS) return cached.counts;
226
+
189
227
  try {
190
- const stateFile = path.join(cwd, ".pi", "takomi", "orchestrator", `${sessionId}.json`);
228
+ const mtimeMs = statSync(stateFile).mtimeMs;
229
+ if (cached && cached.mtimeMs === mtimeMs) {
230
+ boardCountsCache.set(stateFile, { ...cached, checkedAt });
231
+ return cached.counts;
232
+ }
233
+
191
234
  const parsed = JSON.parse(readFileSync(stateFile, "utf8")) as { tasks?: Array<{ status?: string }> };
192
235
  const counts: BoardCounts = { completed: 0, pending: 0, inProgress: 0, blocked: 0 };
193
236
  for (const task of parsed.tasks ?? []) {
@@ -196,8 +239,10 @@ function loadBoardCounts(cwd: string, sessionId?: string): BoardCounts | undefin
196
239
  else if (task.status === "blocked") counts.blocked += 1;
197
240
  else counts.pending += 1;
198
241
  }
242
+ boardCountsCache.set(stateFile, { checkedAt, mtimeMs, counts });
199
243
  return counts;
200
244
  } catch {
245
+ boardCountsCache.set(stateFile, { checkedAt, mtimeMs: 0, counts: undefined });
201
246
  return undefined;
202
247
  }
203
248
  }
@@ -268,7 +313,10 @@ class ContextPanelComponent implements Component {
268
313
 
269
314
  if (ctx) {
270
315
  const age = formatDuration(Date.now() - state.sessionStart);
271
- const active = formatDuration(state.activeMs);
316
+ const pendingStarts = Object.values(state.pendingToolStarts ?? {});
317
+ const pendingStart = pendingStarts.length > 0 ? Math.min(...pendingStarts) : undefined;
318
+ const activeMs = pendingStart !== undefined ? state.activeMs + Math.max(0, Date.now() - pendingStart) : state.activeMs;
319
+ const active = formatDuration(activeMs);
272
320
  lines.push(`${pad}${theme.fg("dim", "Age:")} ${theme.fg("muted", age)}`);
273
321
  lines.push(`${pad}${theme.fg("dim", "Active:")} ${theme.fg("muted", active)}`);
274
322
 
@@ -354,6 +402,7 @@ export class TakomiContextPanel {
354
402
  private overlayHandle?: OverlayHandle;
355
403
  private requestRender?: () => void;
356
404
  private lastCtx?: ExtensionContext;
405
+ private readonly toolStartTimes = new Map<string, number>();
357
406
 
358
407
  getState(): ContextPanelState {
359
408
  return this.state;
@@ -368,6 +417,33 @@ export class TakomiContextPanel {
368
417
  this.requestRender?.();
369
418
  }
370
419
 
420
+ private recomputeActiveMs(): void {
421
+ this.state.activeMs = calculateActiveMs(this.state.activityIntervals);
422
+ }
423
+
424
+ private addActivityInterval(start: number, end: number): void {
425
+ if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return;
426
+ this.state.activityIntervals.push({ start, end });
427
+ this.recomputeActiveMs();
428
+ this.state.lastActivityAt = Math.max(this.state.lastActivityAt, end);
429
+ }
430
+
431
+ private noteActivity(timestamp = Date.now(), allowLongGap = false): void {
432
+ const previous = this.state.lastActivityAt;
433
+ const delta = timestamp - previous;
434
+ if (delta > 0 && (allowLongGap || delta <= ACTIVE_GAP_THRESHOLD_MS)) {
435
+ this.addActivityInterval(previous, timestamp);
436
+ return;
437
+ }
438
+ this.state.lastActivityAt = Math.max(previous, timestamp);
439
+ }
440
+
441
+ private noteToolWindow(toolCallId: string | undefined, start: number, end: number): void {
442
+ this.noteActivity(start);
443
+ this.addActivityInterval(start, end);
444
+ if (toolCallId) delete this.state.pendingToolStarts[toolCallId];
445
+ }
446
+
371
447
  trackFileChange(change: FileChange): void {
372
448
  this.state.fileChanges.push({ ...change, timestamp: change.timestamp || Date.now() });
373
449
  this.state.lastToolAt = Date.now();
@@ -375,21 +451,39 @@ export class TakomiContextPanel {
375
451
  }
376
452
 
377
453
  trackActivity(timestamp = Date.now()): void {
378
- const previous = this.state.lastActivityAt;
379
- const delta = timestamp - previous;
380
- if (delta > 0 && delta <= ACTIVE_GAP_THRESHOLD_MS) this.state.activeMs += delta;
381
- this.state.lastActivityAt = Math.max(previous, timestamp);
454
+ this.noteActivity(timestamp);
455
+ this.requestRender?.();
456
+ }
457
+
458
+ trackToolStart(toolCallId: string | undefined, timestamp = Date.now()): void {
459
+ if (toolCallId) {
460
+ this.toolStartTimes.set(toolCallId, timestamp);
461
+ this.state.pendingToolStarts[toolCallId] = timestamp;
462
+ }
463
+ this.noteActivity(timestamp);
464
+ this.requestRender?.();
465
+ }
466
+
467
+ trackToolEnd(toolCallId: string | undefined, timestamp = Date.now()): void {
468
+ const start = toolCallId ? this.toolStartTimes.get(toolCallId) : undefined;
469
+ if (start !== undefined) {
470
+ this.noteToolWindow(toolCallId, start, timestamp);
471
+ if (toolCallId) this.toolStartTimes.delete(toolCallId);
472
+ } else {
473
+ if (toolCallId) delete this.state.pendingToolStarts[toolCallId];
474
+ this.noteActivity(timestamp);
475
+ }
382
476
  this.requestRender?.();
383
477
  }
384
478
 
385
479
  trackToolUse(toolName: string): void {
386
480
  this.state.toolUses[toolName] = (this.state.toolUses[toolName] ?? 0) + 1;
387
481
  this.state.lastToolAt = Date.now();
388
- this.trackActivity(this.state.lastToolAt);
389
482
  }
390
483
 
391
484
  resetSession(): void {
392
485
  this.state = createEmptyState(Date.now(), this.state.runtime);
486
+ this.toolStartTimes.clear();
393
487
  this.requestRender?.();
394
488
  }
395
489
 
@@ -416,36 +510,65 @@ export class TakomiContextPanel {
416
510
  timestamp?: number;
417
511
  };
418
512
  }>;
419
- const activityTimestamps = branch.map(toTimestampMs).filter((value): value is number => typeof value === "number");
420
- const firstTs = activityTimestamps[0];
421
- const next = createEmptyState(firstTs ?? Date.now(), this.state.runtime);
422
- next.activeMs = calculateActiveMs(activityTimestamps);
423
- next.lastActivityAt = activityTimestamps.at(-1) ?? next.sessionStart;
424
- const toolCalls = new Map<string, { name: string; input: ToolCallInput }>();
513
+ const next = createEmptyState(Date.now(), this.state.runtime);
514
+ const intervals: ActivityInterval[] = [];
515
+ const toolCalls = new Map<string, { name: string; input: ToolCallInput; startedAt: number }>();
516
+ let lastActivityPoint: number | undefined;
517
+ let firstSessionTs: number | undefined;
518
+ this.toolStartTimes.clear();
425
519
 
426
520
  for (const entry of branch) {
521
+ const ts = toTimestampMs(entry);
427
522
  const message = entry.message;
428
- if (!message) continue;
523
+ if (ts !== undefined) firstSessionTs = firstSessionTs === undefined ? ts : Math.min(firstSessionTs, ts);
524
+ if (!message || ts === undefined) continue;
525
+
429
526
  if (message.role === "assistant" && Array.isArray(message.content)) {
527
+ let sawToolCall = false;
430
528
  for (const block of message.content) {
431
529
  const record = asRecord(block);
432
530
  if (record.type !== "toolCall") continue;
433
531
  const id = typeof record.id === "string" ? record.id : undefined;
434
532
  const name = typeof record.name === "string" ? record.name : undefined;
435
533
  if (!id || !name) continue;
436
- toolCalls.set(id, { name, input: asRecord(record.arguments) });
534
+ sawToolCall = true;
535
+ toolCalls.set(id, { name, input: asRecord(record.arguments), startedAt: ts });
536
+ this.toolStartTimes.set(id, ts);
537
+ }
538
+ if (lastActivityPoint !== undefined && ts > lastActivityPoint && ts - lastActivityPoint <= ACTIVE_GAP_THRESHOLD_MS) {
539
+ intervals.push({ start: lastActivityPoint, end: ts });
437
540
  }
541
+ lastActivityPoint = ts;
542
+ if (!sawToolCall) continue;
438
543
  }
439
- if (message.role !== "toolResult" || !message.toolName) continue;
440
- next.toolUses[message.toolName] = (next.toolUses[message.toolName] ?? 0) + 1;
441
- next.lastToolAt = toTimestampMs(entry) ?? next.lastToolAt;
442
- const input = message.toolCallId ? toolCalls.get(message.toolCallId)?.input : undefined;
443
- if (input) {
444
- const change = changeFromTool(message.toolName, input);
445
- if (change) next.fileChanges.push({ ...change, timestamp: toTimestampMs(entry) ?? change.timestamp });
544
+
545
+ if (message.role === "toolResult" && message.toolName) {
546
+ next.toolUses[message.toolName] = (next.toolUses[message.toolName] ?? 0) + 1;
547
+ next.lastToolAt = ts;
548
+ const toolCall = message.toolCallId ? toolCalls.get(message.toolCallId) : undefined;
549
+ if (toolCall) {
550
+ intervals.push({ start: toolCall.startedAt, end: ts });
551
+ const change = changeFromTool(message.toolName, toolCall.input);
552
+ if (change) next.fileChanges.push({ ...change, timestamp: ts });
553
+ if (message.toolCallId) toolCalls.delete(message.toolCallId);
554
+ } else if (lastActivityPoint !== undefined && ts > lastActivityPoint && ts - lastActivityPoint <= ACTIVE_GAP_THRESHOLD_MS) {
555
+ intervals.push({ start: lastActivityPoint, end: ts });
556
+ }
557
+ lastActivityPoint = ts;
558
+ continue;
446
559
  }
560
+
561
+ if (lastActivityPoint !== undefined && ts > lastActivityPoint && ts - lastActivityPoint <= ACTIVE_GAP_THRESHOLD_MS) {
562
+ intervals.push({ start: lastActivityPoint, end: ts });
563
+ }
564
+ lastActivityPoint = ts;
447
565
  }
448
566
 
567
+ next.sessionStart = firstSessionTs ?? next.sessionStart;
568
+ next.activityIntervals = intervals;
569
+ next.activeMs = calculateActiveMs(intervals);
570
+ next.lastActivityAt = lastActivityPoint ?? next.sessionStart;
571
+
449
572
  this.state = next;
450
573
  this.requestRender?.();
451
574
  }
@@ -507,6 +630,7 @@ export function wireContextPanel(pi: ExtensionAPI, panel: TakomiContextPanel): v
507
630
  const pendingWriteActions = new Map<string, "M" | "+">();
508
631
 
509
632
  pi.on("tool_call", (event, ctx) => {
633
+ panel.trackToolStart(event.toolCallId, Date.now());
510
634
  if (event.toolName !== "write") return;
511
635
  const input = asRecord(event.input);
512
636
  const filePath = normalizeToolPath(input);
@@ -524,6 +648,7 @@ export function wireContextPanel(pi: ExtensionAPI, panel: TakomiContextPanel): v
524
648
  pi.on("tool_result", (event) => {
525
649
  const toolName = event.toolName;
526
650
  panel.trackToolUse(toolName);
651
+ panel.trackToolEnd(event.toolCallId, Date.now());
527
652
 
528
653
  const input = asRecord(event.input);
529
654
  const actionOverride = toolName === "write" ? pendingWriteActions.get(event.toolCallId) : undefined;
@@ -891,6 +891,8 @@ export default function takomiRuntime(pi: ExtensionAPI) {
891
891
  "Session IDs must use the canonical timestamp format orch-YYYYMMDD-HHMMSS. Use the same sessionId for the authored docs folder and the board JSON state.",
892
892
  "For high-quality orchestration sessions, provide sessionId, masterPlanMarkdown, and taskMarkdown values that match the authored session folder. If you already wrote docs/tasks/orchestrator-sessions/<id>, call this tool with sessionId=<id>; do not create a second session id.",
893
893
  "JSON fields should carry IDs/status/roles/workflow/dependencies/checklists for tracking, not replace expressive markdown.",
894
+ "Do not use expand_stage as a placeholder generator. For Design/Build expansions, provide full taskMarkdown or complete objective/scope/definitionOfDone/expectedArtifacts/instructions for every task.",
895
+ "If a task packet would render with Scope/Definition Of Done/Expected Artifacts as None specified, repair it before launching subagents.",
894
896
  "A new session should normally begin Genesis-first, then expand Design and Build into as many tasks as the scope actually needs.",
895
897
  "If the request is small enough, do not force orchestration just because the tool exists.",
896
898
  "If a reviewed task needs more work, reuse the task conversationId when you call takomi_subagent again, then update the board with the new result.",