takomi 2.1.29 → 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.
@@ -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;
@@ -76,6 +78,11 @@ type BoardCountsCacheEntry = {
76
78
  counts?: BoardCounts;
77
79
  };
78
80
 
81
+ type ActivityInterval = {
82
+ start: number;
83
+ end: number;
84
+ };
85
+
79
86
  const BOARD_COUNTS_CACHE_MS = 1_000;
80
87
  const boardCountsCache = new Map<string, BoardCountsCacheEntry>();
81
88
 
@@ -85,6 +92,8 @@ function createEmptyState(sessionStart = Date.now(), runtime: ContextRuntimeStat
85
92
  toolUses: {},
86
93
  sessionStart,
87
94
  activeMs: 0,
95
+ activityIntervals: [],
96
+ pendingToolStarts: {},
88
97
  lastActivityAt: sessionStart,
89
98
  lastToolAt: 0,
90
99
  runtime,
@@ -173,14 +182,28 @@ function toTimestampMs(entry: { timestamp?: unknown; message?: { timestamp?: unk
173
182
  return undefined;
174
183
  }
175
184
 
176
- function calculateActiveMs(timestamps: number[]): number {
177
- const sorted = [...new Set(timestamps.filter((value) => Number.isFinite(value)))].sort((a, b) => a - b);
178
- 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]];
179
193
  for (let i = 1; i < sorted.length; i += 1) {
180
- const delta = sorted[i] - sorted[i - 1];
181
- 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
+ }
182
201
  }
183
- 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);
184
207
  }
185
208
 
186
209
  function formatDisplayPath(filePath: string, cwd?: string): string {
@@ -290,7 +313,10 @@ class ContextPanelComponent implements Component {
290
313
 
291
314
  if (ctx) {
292
315
  const age = formatDuration(Date.now() - state.sessionStart);
293
- 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);
294
320
  lines.push(`${pad}${theme.fg("dim", "Age:")} ${theme.fg("muted", age)}`);
295
321
  lines.push(`${pad}${theme.fg("dim", "Active:")} ${theme.fg("muted", active)}`);
296
322
 
@@ -376,6 +402,7 @@ export class TakomiContextPanel {
376
402
  private overlayHandle?: OverlayHandle;
377
403
  private requestRender?: () => void;
378
404
  private lastCtx?: ExtensionContext;
405
+ private readonly toolStartTimes = new Map<string, number>();
379
406
 
380
407
  getState(): ContextPanelState {
381
408
  return this.state;
@@ -390,6 +417,33 @@ export class TakomiContextPanel {
390
417
  this.requestRender?.();
391
418
  }
392
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
+
393
447
  trackFileChange(change: FileChange): void {
394
448
  this.state.fileChanges.push({ ...change, timestamp: change.timestamp || Date.now() });
395
449
  this.state.lastToolAt = Date.now();
@@ -397,21 +451,39 @@ export class TakomiContextPanel {
397
451
  }
398
452
 
399
453
  trackActivity(timestamp = Date.now()): void {
400
- const previous = this.state.lastActivityAt;
401
- const delta = timestamp - previous;
402
- if (delta > 0 && delta <= ACTIVE_GAP_THRESHOLD_MS) this.state.activeMs += delta;
403
- 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
+ }
404
476
  this.requestRender?.();
405
477
  }
406
478
 
407
479
  trackToolUse(toolName: string): void {
408
480
  this.state.toolUses[toolName] = (this.state.toolUses[toolName] ?? 0) + 1;
409
481
  this.state.lastToolAt = Date.now();
410
- this.trackActivity(this.state.lastToolAt);
411
482
  }
412
483
 
413
484
  resetSession(): void {
414
485
  this.state = createEmptyState(Date.now(), this.state.runtime);
486
+ this.toolStartTimes.clear();
415
487
  this.requestRender?.();
416
488
  }
417
489
 
@@ -438,36 +510,65 @@ export class TakomiContextPanel {
438
510
  timestamp?: number;
439
511
  };
440
512
  }>;
441
- const activityTimestamps = branch.map(toTimestampMs).filter((value): value is number => typeof value === "number");
442
- const firstTs = activityTimestamps[0];
443
- const next = createEmptyState(firstTs ?? Date.now(), this.state.runtime);
444
- next.activeMs = calculateActiveMs(activityTimestamps);
445
- next.lastActivityAt = activityTimestamps.at(-1) ?? next.sessionStart;
446
- 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();
447
519
 
448
520
  for (const entry of branch) {
521
+ const ts = toTimestampMs(entry);
449
522
  const message = entry.message;
450
- if (!message) continue;
523
+ if (ts !== undefined) firstSessionTs = firstSessionTs === undefined ? ts : Math.min(firstSessionTs, ts);
524
+ if (!message || ts === undefined) continue;
525
+
451
526
  if (message.role === "assistant" && Array.isArray(message.content)) {
527
+ let sawToolCall = false;
452
528
  for (const block of message.content) {
453
529
  const record = asRecord(block);
454
530
  if (record.type !== "toolCall") continue;
455
531
  const id = typeof record.id === "string" ? record.id : undefined;
456
532
  const name = typeof record.name === "string" ? record.name : undefined;
457
533
  if (!id || !name) continue;
458
- 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);
459
537
  }
538
+ if (lastActivityPoint !== undefined && ts > lastActivityPoint && ts - lastActivityPoint <= ACTIVE_GAP_THRESHOLD_MS) {
539
+ intervals.push({ start: lastActivityPoint, end: ts });
540
+ }
541
+ lastActivityPoint = ts;
542
+ if (!sawToolCall) continue;
460
543
  }
461
- if (message.role !== "toolResult" || !message.toolName) continue;
462
- next.toolUses[message.toolName] = (next.toolUses[message.toolName] ?? 0) + 1;
463
- next.lastToolAt = toTimestampMs(entry) ?? next.lastToolAt;
464
- const input = message.toolCallId ? toolCalls.get(message.toolCallId)?.input : undefined;
465
- if (input) {
466
- const change = changeFromTool(message.toolName, input);
467
- 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;
559
+ }
560
+
561
+ if (lastActivityPoint !== undefined && ts > lastActivityPoint && ts - lastActivityPoint <= ACTIVE_GAP_THRESHOLD_MS) {
562
+ intervals.push({ start: lastActivityPoint, end: ts });
468
563
  }
564
+ lastActivityPoint = ts;
469
565
  }
470
566
 
567
+ next.sessionStart = firstSessionTs ?? next.sessionStart;
568
+ next.activityIntervals = intervals;
569
+ next.activeMs = calculateActiveMs(intervals);
570
+ next.lastActivityAt = lastActivityPoint ?? next.sessionStart;
571
+
471
572
  this.state = next;
472
573
  this.requestRender?.();
473
574
  }
@@ -529,6 +630,7 @@ export function wireContextPanel(pi: ExtensionAPI, panel: TakomiContextPanel): v
529
630
  const pendingWriteActions = new Map<string, "M" | "+">();
530
631
 
531
632
  pi.on("tool_call", (event, ctx) => {
633
+ panel.trackToolStart(event.toolCallId, Date.now());
532
634
  if (event.toolName !== "write") return;
533
635
  const input = asRecord(event.input);
534
636
  const filePath = normalizeToolPath(input);
@@ -546,6 +648,7 @@ export function wireContextPanel(pi: ExtensionAPI, panel: TakomiContextPanel): v
546
648
  pi.on("tool_result", (event) => {
547
649
  const toolName = event.toolName;
548
650
  panel.trackToolUse(toolName);
651
+ panel.trackToolEnd(event.toolCallId, Date.now());
549
652
 
550
653
  const input = asRecord(event.input);
551
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.",