@dungle-scrubs/tallow 0.8.24 → 0.8.26

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.
Files changed (60) hide show
  1. package/dist/auth-hardening.d.ts +12 -0
  2. package/dist/auth-hardening.d.ts.map +1 -1
  3. package/dist/auth-hardening.js +30 -7
  4. package/dist/auth-hardening.js.map +1 -1
  5. package/dist/cli.js +5 -0
  6. package/dist/cli.js.map +1 -1
  7. package/dist/config.d.ts +1 -1
  8. package/dist/config.js +1 -1
  9. package/dist/install.js +2 -2
  10. package/dist/install.js.map +1 -1
  11. package/dist/interactive-mode-patch.d.ts.map +1 -1
  12. package/dist/interactive-mode-patch.js +119 -7
  13. package/dist/interactive-mode-patch.js.map +1 -1
  14. package/dist/model-metadata-overrides.d.ts +19 -0
  15. package/dist/model-metadata-overrides.d.ts.map +1 -0
  16. package/dist/model-metadata-overrides.js +38 -0
  17. package/dist/model-metadata-overrides.js.map +1 -0
  18. package/dist/sdk.d.ts +2 -0
  19. package/dist/sdk.d.ts.map +1 -1
  20. package/dist/sdk.js +28 -1
  21. package/dist/sdk.js.map +1 -1
  22. package/extensions/__integration__/teams-runtime.test.ts +22 -1
  23. package/extensions/_shared/__tests__/shell-policy.test.ts +197 -0
  24. package/extensions/_shared/shell-policy.ts +27 -0
  25. package/extensions/background-task-tool/index.ts +2 -1
  26. package/extensions/bash-tool-enhanced/index.ts +2 -1
  27. package/extensions/custom-footer/__tests__/index.test.ts +29 -0
  28. package/extensions/custom-footer/context-display.ts +49 -0
  29. package/extensions/custom-footer/index.ts +10 -23
  30. package/extensions/permissions/index.ts +31 -10
  31. package/extensions/plan-mode-tool/__tests__/index.test.ts +32 -2
  32. package/extensions/plan-mode-tool/index.ts +6 -1
  33. package/extensions/skill-commands/__tests__/shared-skills-dirs.test.ts +113 -0
  34. package/extensions/skill-commands/index.ts +62 -5
  35. package/extensions/slash-command-bridge/index.ts +30 -1
  36. package/extensions/subagent-tool/__tests__/process-liveness.test.ts +42 -3
  37. package/extensions/subagent-tool/process.ts +132 -21
  38. package/extensions/tasks/__tests__/store.test.ts +26 -2
  39. package/extensions/tasks/commands/register-tasks-extension.ts +2 -2
  40. package/extensions/tasks/index.ts +5 -5
  41. package/extensions/tasks/state/index.ts +90 -36
  42. package/extensions/teams-tool/__tests__/archive-store.test.ts +98 -0
  43. package/extensions/teams-tool/__tests__/peer-messaging.test.ts +26 -0
  44. package/extensions/teams-tool/archive-store.ts +200 -0
  45. package/extensions/teams-tool/sessions/spawn.ts +244 -71
  46. package/extensions/teams-tool/tools/register-extension.ts +146 -105
  47. package/extensions/teams-tool/tools/teammate-tools.ts +43 -1
  48. package/node_modules/@mariozechner/pi-tui/dist/keys.d.ts.map +1 -1
  49. package/node_modules/@mariozechner/pi-tui/dist/keys.js +59 -7
  50. package/node_modules/@mariozechner/pi-tui/dist/keys.js.map +1 -1
  51. package/node_modules/@mariozechner/pi-tui/package.json +1 -1
  52. package/node_modules/@mariozechner/pi-tui/src/keys.ts +71 -7
  53. package/package.json +5 -5
  54. package/skills/tallow-expert/SKILL.md +1 -1
  55. package/templates/agents/architect.md +13 -5
  56. package/templates/agents/debug.md +3 -3
  57. package/templates/agents/explore.md +9 -2
  58. package/templates/agents/refactor.md +2 -2
  59. package/templates/agents/scout.md +3 -2
  60. package/extensions/__integration__/plan-rejection-feedback.test.ts +0 -272
@@ -448,10 +448,12 @@ export interface ForegroundWatchdogThresholds {
448
448
  readonly inactivityTimeoutMs: number;
449
449
  readonly killGraceMs: number;
450
450
  readonly startupTimeoutMs: number;
451
+ readonly toolExecutionTimeoutMs: number;
451
452
  }
452
453
 
453
454
  /** Heartbeat state tracked by the foreground subagent liveness watchdog. */
454
455
  export interface WatchdogHeartbeatState {
456
+ readonly activeToolCalls: number;
455
457
  readonly lastHeartbeatAtMs: number | null;
456
458
  readonly startedAtMs: number;
457
459
  }
@@ -462,20 +464,86 @@ export type WatchdogStatus =
462
464
  | {
463
465
  readonly elapsedMs: number;
464
466
  readonly kind: "stalled";
465
- readonly phase: "inactivity" | "startup";
467
+ readonly phase: "inactivity" | "startup" | "tool_execution";
466
468
  readonly timeoutMs: number;
467
469
  };
468
470
 
471
+ /** Env var overriding the foreground startup timeout. */
472
+ export const SUBAGENT_STARTUP_TIMEOUT_MS_ENV = "TALLOW_SUBAGENT_STARTUP_TIMEOUT_MS";
473
+
474
+ /** Env var overriding the foreground inactivity timeout when no tool is active. */
475
+ export const SUBAGENT_INACTIVITY_TIMEOUT_MS_ENV = "TALLOW_SUBAGENT_INACTIVITY_TIMEOUT_MS";
476
+
477
+ /** Env var overriding the foreground timeout while a tool call is still running. */
478
+ export const SUBAGENT_TOOL_EXECUTION_TIMEOUT_MS_ENV = "TALLOW_SUBAGENT_TOOL_EXECUTION_TIMEOUT_MS";
479
+
480
+ /** Env var overriding the SIGTERM → SIGKILL grace window for stalled workers. */
481
+ export const SUBAGENT_WATCHDOG_KILL_GRACE_MS_ENV = "TALLOW_SUBAGENT_WATCHDOG_KILL_GRACE_MS";
482
+
469
483
  /** Default watchdog thresholds used by foreground subagents in runSingleAgent. */
470
484
  export const FOREGROUND_WATCHDOG_THRESHOLDS: ForegroundWatchdogThresholds = {
471
- inactivityTimeoutMs: 90_000,
485
+ inactivityTimeoutMs: 180_000,
472
486
  killGraceMs: 5_000,
473
- startupTimeoutMs: 30_000,
487
+ startupTimeoutMs: 60_000,
488
+ toolExecutionTimeoutMs: 600_000,
474
489
  };
475
490
 
476
491
  /** How often the foreground watchdog checks for stalled subagents. */
477
492
  const FOREGROUND_WATCHDOG_CHECK_INTERVAL_MS = 500;
478
493
 
494
+ /** Foreground event types that count as liveness without changing tool-call state. */
495
+ const WATCHDOG_HEARTBEAT_EVENT_TYPES = new Set([
496
+ "message_end",
497
+ "message_update",
498
+ "tool_execution_end",
499
+ "tool_execution_start",
500
+ ]);
501
+
502
+ /**
503
+ * Parse a positive millisecond timeout override.
504
+ * @param rawValue - Raw env value
505
+ * @returns Parsed timeout in milliseconds, or undefined when invalid
506
+ */
507
+ function parseTimeoutOverrideMs(rawValue: string | undefined): number | undefined {
508
+ if (!rawValue) return undefined;
509
+ const parsed = Number.parseInt(rawValue, 10);
510
+ if (Number.isNaN(parsed) || !Number.isFinite(parsed) || parsed <= 0) return undefined;
511
+ return parsed;
512
+ }
513
+
514
+ /**
515
+ * Resolve effective watchdog thresholds from env overrides.
516
+ * @param env - Environment lookup map
517
+ * @returns Watchdog thresholds used for this foreground worker
518
+ */
519
+ export function resolveForegroundWatchdogThresholds(
520
+ env: EnvLookup = process.env
521
+ ): ForegroundWatchdogThresholds {
522
+ return {
523
+ inactivityTimeoutMs:
524
+ parseTimeoutOverrideMs(env[SUBAGENT_INACTIVITY_TIMEOUT_MS_ENV]) ??
525
+ FOREGROUND_WATCHDOG_THRESHOLDS.inactivityTimeoutMs,
526
+ killGraceMs:
527
+ parseTimeoutOverrideMs(env[SUBAGENT_WATCHDOG_KILL_GRACE_MS_ENV]) ??
528
+ FOREGROUND_WATCHDOG_THRESHOLDS.killGraceMs,
529
+ startupTimeoutMs:
530
+ parseTimeoutOverrideMs(env[SUBAGENT_STARTUP_TIMEOUT_MS_ENV]) ??
531
+ FOREGROUND_WATCHDOG_THRESHOLDS.startupTimeoutMs,
532
+ toolExecutionTimeoutMs:
533
+ parseTimeoutOverrideMs(env[SUBAGENT_TOOL_EXECUTION_TIMEOUT_MS_ENV]) ??
534
+ FOREGROUND_WATCHDOG_THRESHOLDS.toolExecutionTimeoutMs,
535
+ };
536
+ }
537
+
538
+ /**
539
+ * Return whether an event type counts as watchdog progress.
540
+ * @param eventType - Raw child-process event type
541
+ * @returns True when the event should refresh liveness
542
+ */
543
+ export function isWatchdogHeartbeatEventType(eventType: string): boolean {
544
+ return WATCHDOG_HEARTBEAT_EVENT_TYPES.has(eventType);
545
+ }
546
+
479
547
  /**
480
548
  * Create initial watchdog heartbeat state.
481
549
  * @param nowMs - Current wall-clock timestamp in milliseconds
@@ -483,6 +551,7 @@ const FOREGROUND_WATCHDOG_CHECK_INTERVAL_MS = 500;
483
551
  */
484
552
  export function createWatchdogHeartbeatState(nowMs: number): WatchdogHeartbeatState {
485
553
  return {
554
+ activeToolCalls: 0,
486
555
  lastHeartbeatAtMs: null,
487
556
  startedAtMs: nowMs,
488
557
  };
@@ -504,6 +573,40 @@ export function recordWatchdogHeartbeat(
504
573
  };
505
574
  }
506
575
 
576
+ /**
577
+ * Record the start of a tool call for watchdog timeout widening.
578
+ * @param state - Existing watchdog heartbeat state
579
+ * @param nowMs - Current wall-clock timestamp in milliseconds
580
+ * @returns Updated heartbeat state
581
+ */
582
+ export function recordWatchdogToolCallStart(
583
+ state: WatchdogHeartbeatState,
584
+ nowMs: number
585
+ ): WatchdogHeartbeatState {
586
+ return {
587
+ activeToolCalls: state.activeToolCalls + 1,
588
+ lastHeartbeatAtMs: nowMs,
589
+ startedAtMs: state.startedAtMs,
590
+ };
591
+ }
592
+
593
+ /**
594
+ * Record the completion of a tool call for watchdog timeout narrowing.
595
+ * @param state - Existing watchdog heartbeat state
596
+ * @param nowMs - Current wall-clock timestamp in milliseconds
597
+ * @returns Updated heartbeat state
598
+ */
599
+ export function recordWatchdogToolCallEnd(
600
+ state: WatchdogHeartbeatState,
601
+ nowMs: number
602
+ ): WatchdogHeartbeatState {
603
+ return {
604
+ activeToolCalls: Math.max(0, state.activeToolCalls - 1),
605
+ lastHeartbeatAtMs: nowMs,
606
+ startedAtMs: state.startedAtMs,
607
+ };
608
+ }
609
+
507
610
  /**
508
611
  * Evaluate current liveness state against watchdog thresholds.
509
612
  * @param state - Current heartbeat state
@@ -530,12 +633,14 @@ export function evaluateWatchdogStatus(
530
633
  }
531
634
 
532
635
  const inactivityElapsedMs = nowMs - state.lastHeartbeatAtMs;
533
- if (inactivityElapsedMs >= thresholds.inactivityTimeoutMs) {
636
+ const timeoutMs =
637
+ state.activeToolCalls > 0 ? thresholds.toolExecutionTimeoutMs : thresholds.inactivityTimeoutMs;
638
+ if (inactivityElapsedMs >= timeoutMs) {
534
639
  return {
535
640
  elapsedMs: inactivityElapsedMs,
536
641
  kind: "stalled",
537
- phase: "inactivity",
538
- timeoutMs: thresholds.inactivityTimeoutMs,
642
+ phase: state.activeToolCalls > 0 ? "tool_execution" : "inactivity",
643
+ timeoutMs,
539
644
  };
540
645
  }
541
646
  return { kind: "healthy" };
@@ -552,9 +657,16 @@ export function createStalledSubagentErrorMessage(
552
657
  const timeoutSeconds = Math.max(1, Math.round(stalledStatus.timeoutMs / 1000));
553
658
  const phaseDescription =
554
659
  stalledStatus.phase === "startup"
555
- ? "no startup heartbeat was received"
556
- : `no heartbeat was received for ${timeoutSeconds}s`;
557
- return `Subagent stalled (${phaseDescription}). Likely deadlock: waiting for an interactive confirmation path unavailable in subagent JSON mode. Action: avoid confirmation-gated steps, pre-authorize required tools, or run this step in the parent agent.`;
660
+ ? "no startup activity was received"
661
+ : stalledStatus.phase === "tool_execution"
662
+ ? `no subagent activity was received for ${timeoutSeconds}s while a tool call was running`
663
+ : `no subagent activity was received for ${timeoutSeconds}s`;
664
+ return (
665
+ `Subagent stalled (${phaseDescription}). Common causes: slow provider startup, long-running tool execution without progress events, ` +
666
+ "or an interactive confirmation path unavailable in subagent JSON mode. " +
667
+ "Action: narrow task scope, avoid confirmation-gated steps, run very long commands in the parent agent, " +
668
+ "or increase TALLOW_SUBAGENT_* timeout env vars when slow work is legitimate."
669
+ );
558
670
  }
559
671
 
560
672
  /**
@@ -1251,6 +1363,7 @@ export async function runSingleAgent(
1251
1363
  if (!foregroundSpawn.ok) {
1252
1364
  throw new Error(foregroundSpawn.reason);
1253
1365
  }
1366
+ const watchdogThresholds = resolveForegroundWatchdogThresholds();
1254
1367
  const exitCode = await new Promise<number>((resolve) => {
1255
1368
  const proc = foregroundSpawn.proc;
1256
1369
  if (!proc.stdout || !proc.stderr) {
@@ -1292,7 +1405,7 @@ export async function runSingleAgent(
1292
1405
  if (stopRequested) return;
1293
1406
  stopRequested = true;
1294
1407
  stopHandle = terminateProcessWithGrace(proc, {
1295
- killGraceMs: FOREGROUND_WATCHDOG_THRESHOLDS.killGraceMs,
1408
+ killGraceMs: watchdogThresholds.killGraceMs,
1296
1409
  onForceResolve: () => {
1297
1410
  settle(1);
1298
1411
  },
@@ -1309,16 +1422,14 @@ export async function runSingleAgent(
1309
1422
  return;
1310
1423
  }
1311
1424
 
1312
- if (
1313
- event.type === "message_end" ||
1314
- event.type === "tool_call_start" ||
1315
- event.type === "tool_result_end"
1316
- ) {
1317
- heartbeatState = recordWatchdogHeartbeat(heartbeatState, Date.now());
1425
+ const nowMs = Date.now();
1426
+ if (isWatchdogHeartbeatEventType(String(event.type))) {
1427
+ heartbeatState = recordWatchdogHeartbeat(heartbeatState, nowMs);
1318
1428
  }
1319
1429
 
1320
1430
  // Emit subagent_tool_call when tool starts
1321
1431
  if (event.type === "tool_call_start") {
1432
+ heartbeatState = recordWatchdogToolCallStart(heartbeatState, nowMs);
1322
1433
  fgTurnCount++;
1323
1434
  // Hard enforcement: kill after maxTurns tool calls
1324
1435
  if (agent.maxTurns && fgTurnCount >= agent.maxTurns) {
@@ -1360,6 +1471,10 @@ export async function runSingleAgent(
1360
1471
  emitUpdate();
1361
1472
  }
1362
1473
 
1474
+ if (event.type === "tool_result_end") {
1475
+ heartbeatState = recordWatchdogToolCallEnd(heartbeatState, nowMs);
1476
+ }
1477
+
1363
1478
  if (event.type === "tool_result_end" && event.message) {
1364
1479
  currentResult.messages.push(event.message as Message);
1365
1480
  // Detect permission denials vs regular errors
@@ -1385,11 +1500,7 @@ export async function runSingleAgent(
1385
1500
 
1386
1501
  watchdogInterval = setInterval(() => {
1387
1502
  if (isResolved || stopRequested) return;
1388
- const status = evaluateWatchdogStatus(
1389
- heartbeatState,
1390
- Date.now(),
1391
- FOREGROUND_WATCHDOG_THRESHOLDS
1392
- );
1503
+ const status = evaluateWatchdogStatus(heartbeatState, Date.now(), watchdogThresholds);
1393
1504
  if (status.kind !== "stalled") return;
1394
1505
  applyStalledClassification(currentResult, status);
1395
1506
  setForegroundSubagentStatus(taskId, "stalled", piEvents);
@@ -3,9 +3,9 @@
3
3
  * corruption tolerance, and session-only mode.
4
4
  */
5
5
  import { afterEach, describe, expect, it } from "bun:test";
6
- import { existsSync, rmSync, writeFileSync } from "node:fs";
6
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
7
7
  import { join } from "node:path";
8
- import { type Task, TaskListStore } from "../state/index.js";
8
+ import { LEGACY_TEAMS_DIR, TASK_GROUPS_DIR, type Task, TaskListStore } from "../state/index.js";
9
9
 
10
10
  /**
11
11
  * Create a minimal task for store tests.
@@ -119,6 +119,30 @@ describe("TaskListStore file-backed mode", () => {
119
119
 
120
120
  expect(ctx.store.isShared).toBe(true);
121
121
  expect(existsSync(ctx.dir)).toBe(true);
122
+ expect(ctx.dir.startsWith(TASK_GROUPS_DIR)).toBe(true);
123
+ });
124
+
125
+ it("migrates a legacy ~/.tallow/teams task directory into task-groups", () => {
126
+ const teamName = `legacy-${Date.now()}`;
127
+ const legacyDir = join(LEGACY_TEAMS_DIR, teamName, "tasks");
128
+ const nextDir = join(TASK_GROUPS_DIR, teamName, "tasks");
129
+ mkdirSync(legacyDir, { recursive: true });
130
+ writeFileSync(join(legacyDir, "1.json"), JSON.stringify(makeTask("1", "From legacy")), "utf-8");
131
+
132
+ const store = new TaskListStore(teamName);
133
+ stores.push({
134
+ cleanup: () => {
135
+ store.deleteAll();
136
+ store.close();
137
+ rmSync(join(nextDir, ".."), { recursive: true, force: true });
138
+ rmSync(join(legacyDir, ".."), { recursive: true, force: true });
139
+ },
140
+ });
141
+
142
+ expect(store.path).toBe(nextDir);
143
+ expect(existsSync(join(nextDir, "1.json"))).toBe(true);
144
+ expect(existsSync(join(legacyDir, "1.json"))).toBe(false);
145
+ expect(store.loadAll()?.[0].subject).toBe("From legacy");
122
146
  });
123
147
 
124
148
  it("saveTask persists and loadAll retrieves", () => {
@@ -1093,7 +1093,7 @@ export function registerTasksExtension(
1093
1093
  case "team": {
1094
1094
  const current = store.isShared ? process.env.PI_TEAM_NAME : "(none — session-only)";
1095
1095
  const teamPath = store.path ?? "N/A";
1096
- ctx.ui.notify(`Team: ${current}\nPath: ${teamPath}`, "info");
1096
+ ctx.ui.notify(`Shared task group: ${current}\nPath: ${teamPath}`, "info");
1097
1097
  break;
1098
1098
  }
1099
1099
 
@@ -1122,7 +1122,7 @@ export function registerTasksExtension(
1122
1122
  " delete <n> - Delete task n\n" +
1123
1123
  " clear - Clear all tasks\n" +
1124
1124
  " toggle - Show/hide task widget\n" +
1125
- " team - Show current team name and path",
1125
+ " team - Show current shared task group and path",
1126
1126
  "info"
1127
1127
  );
1128
1128
  }
@@ -5,7 +5,7 @@
5
5
  * - Three states: pending (☐), in-progress (◉), completed (☑)
6
6
  * - Bidirectional dependency tracking (blocks/blockedBy)
7
7
  * - Comments for cross-session handoff context
8
- * - Team-based sharing via ~/.tallow/teams/{team-name}/tasks/
8
+ * - Shared task groups via ~/.tallow/task-groups/{group-name}/tasks/
9
9
  * - Multi-session coordination via fs.watch
10
10
  * - One file per task (avoids write conflicts)
11
11
  * - Status widget with dynamic sizing
@@ -15,7 +15,7 @@
15
15
  * NOTE: This extension only runs in the main Pi process, not in subagent workers.
16
16
  *
17
17
  * This file is the composition root: it constructs shared infrastructure
18
- * (team name, file store) and delegates all registration to
18
+ * (shared task-group name, file store) and delegates all registration to
19
19
  * {@link registerTasksExtension}. Domain logic lives in sibling modules.
20
20
  */
21
21
 
@@ -43,10 +43,10 @@ export { shouldClearOnAgentEnd } from "./state/index.js";
43
43
  export default function tasksExtension(pi: ExtensionAPI): void {
44
44
  const isSubagent = process.env.PI_IS_SUBAGENT === "1";
45
45
 
46
- // Auto-generate a team name so subagents can coordinate via shared directory.
47
- // Subagents inherit PI_TEAM_NAME from the lead process automatically.
46
+ // Auto-generate a shared task-group name so subagents can coordinate via a
47
+ // file-backed store. PI_TEAM_NAME stays as the env var for backward compatibility.
48
48
  const teamName =
49
- process.env.PI_TEAM_NAME ?? (isSubagent ? null : `team-${randomUUID().slice(0, 8)}`);
49
+ process.env.PI_TEAM_NAME ?? (isSubagent ? null : `task-group-${randomUUID().slice(0, 8)}`);
50
50
  if (teamName && !process.env.PI_TEAM_NAME) {
51
51
  // Set on process.env so child subagents inherit it automatically
52
52
  process.env.PI_TEAM_NAME = teamName;
@@ -12,13 +12,14 @@ import {
12
12
  mkdirSync,
13
13
  readdirSync,
14
14
  readFileSync,
15
+ renameSync,
15
16
  rmdirSync,
16
17
  rmSync,
17
18
  statSync,
18
19
  unlinkSync,
19
20
  watch,
20
21
  } from "node:fs";
21
- import { join } from "node:path";
22
+ import { dirname, join } from "node:path";
22
23
  import type { AgentMessage } from "@mariozechner/pi-agent-core";
23
24
  import type { AssistantMessage, TextContent } from "@mariozechner/pi-ai";
24
25
  import { atomicWriteFileSync } from "../../_shared/atomic-write.js";
@@ -26,8 +27,11 @@ import { getTallowPath } from "../../_shared/tallow-paths.js";
26
27
 
27
28
  // ── Constants ────────────────────────────────────────────────────────────────
28
29
 
29
- /** Directory root for team-based shared task lists. */
30
- export const TEAMS_DIR = getTallowPath("teams");
30
+ /** Directory root for shared task-group state used by tasks/subagents. */
31
+ export const TASK_GROUPS_DIR = getTallowPath("task-groups");
32
+
33
+ /** Legacy directory root from older builds before task groups were split from teams. */
34
+ export const LEGACY_TEAMS_DIR = getTallowPath("teams");
31
35
 
32
36
  /** Max age for team directories before cleanup (7 days in ms). */
33
37
  export const TEAM_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
@@ -120,6 +124,30 @@ export function nextTaskId(state: TasksState): string {
120
124
  return id;
121
125
  }
122
126
 
127
+ /**
128
+ * Move a legacy shared-task directory into the new task-groups root.
129
+ *
130
+ * Older builds stored shared task state under `~/.tallow/teams/`, which
131
+ * collided conceptually with the teams runtime. New builds use
132
+ * `~/.tallow/task-groups/`. Migration is best-effort and intentionally silent.
133
+ *
134
+ * @param currentDirPath - New task directory path under task-groups/
135
+ * @param legacyDirPath - Legacy task directory path under teams/
136
+ * @returns void
137
+ */
138
+ function migrateLegacyTaskGroupDir(currentDirPath: string, legacyDirPath: string): void {
139
+ if (existsSync(currentDirPath) || !existsSync(legacyDirPath)) return;
140
+ const currentTeamDir = dirname(currentDirPath);
141
+ const legacyTeamDir = dirname(legacyDirPath);
142
+ mkdirSync(dirname(currentTeamDir), { recursive: true });
143
+ try {
144
+ renameSync(legacyTeamDir, currentTeamDir);
145
+ } catch {
146
+ // Best-effort migration only. If this fails, the session will recreate the
147
+ // new directory and continue without blocking startup.
148
+ }
149
+ }
150
+
123
151
  // ── Message helpers ──────────────────────────────────────────────────────────
124
152
 
125
153
  /**
@@ -150,15 +178,17 @@ export function getTextContent(message: AssistantMessage): string {
150
178
  /**
151
179
  * Persistent, file-backed task store for cross-session sharing.
152
180
  *
153
- * Each team gets a directory at `~/.tallow/teams/{team-name}/tasks/` containing
154
- * one JSON file per task. `fs.watch` on the directory detects changes from
155
- * other sessions sharing the same team.
181
+ * Each shared task group gets a directory at
182
+ * `~/.tallow/task-groups/{group-name}/tasks/` containing one JSON file per
183
+ * task. `fs.watch` on the directory detects changes from other sessions
184
+ * sharing the same task group.
156
185
  *
157
186
  * Without a team name, this store is inactive and the extension falls back
158
187
  * to session-entry persistence.
159
188
  */
160
189
  export class TaskListStore {
161
190
  private readonly dirPath: string | null;
191
+ private readonly legacyDirPath: string | null;
162
192
  private watcher: FSWatcher | null = null;
163
193
  private onChange: (() => void) | null = null;
164
194
  /** Debounce timer to coalesce rapid file change events. */
@@ -172,10 +202,13 @@ export class TaskListStore {
172
202
  constructor(teamName: string | null) {
173
203
  if (teamName) {
174
204
  const safeName = teamName.replace(/[^a-zA-Z0-9._-]/g, "_");
175
- this.dirPath = join(TEAMS_DIR, safeName, "tasks");
205
+ this.dirPath = join(TASK_GROUPS_DIR, safeName, "tasks");
206
+ this.legacyDirPath = join(LEGACY_TEAMS_DIR, safeName, "tasks");
207
+ migrateLegacyTaskGroupDir(this.dirPath, this.legacyDirPath);
176
208
  mkdirSync(this.dirPath, { recursive: true });
177
209
  } else {
178
210
  this.dirPath = null;
211
+ this.legacyDirPath = null;
179
212
  }
180
213
  }
181
214
 
@@ -196,14 +229,23 @@ export class TaskListStore {
196
229
  */
197
230
  loadAll(): Task[] | null {
198
231
  if (!this.dirPath) return null;
199
- if (!existsSync(this.dirPath)) return [];
200
-
232
+ const hasCurrentDir = existsSync(this.dirPath);
233
+ const hasLegacyDir = this.legacyDirPath ? existsSync(this.legacyDirPath) : false;
234
+ if (!hasCurrentDir && !hasLegacyDir) return [];
235
+
236
+ const currentFiles = hasCurrentDir
237
+ ? readdirSync(this.dirPath).filter((fileName) => fileName.endsWith(".json"))
238
+ : [];
239
+ const readDirPath =
240
+ currentFiles.length > 0 || !hasLegacyDir || !this.legacyDirPath
241
+ ? this.dirPath
242
+ : this.legacyDirPath;
201
243
  const tasks: Task[] = [];
202
244
  try {
203
- const files = readdirSync(this.dirPath).filter((f) => f.endsWith(".json"));
245
+ const files = readdirSync(readDirPath).filter((f) => f.endsWith(".json"));
204
246
  for (const file of files) {
205
247
  try {
206
- const raw = readFileSync(join(this.dirPath, file), "utf-8");
248
+ const raw = readFileSync(join(readDirPath, file), "utf-8");
207
249
  const parsed = JSON.parse(raw) as Record<string, unknown>;
208
250
  // Migrate old schema: title → subject, dependencies → blockedBy
209
251
  if (parsed.title && !parsed.subject) {
@@ -386,37 +428,49 @@ export class TaskListStore {
386
428
  }
387
429
 
388
430
  /**
389
- * Remove team directories older than {@link TEAM_MAX_AGE_MS}.
431
+ * Remove stale task-group directories from one root.
390
432
  *
391
- * Skips the current team (if any) to avoid deleting an active session.
392
- * Runs once per session start errors are silently ignored.
433
+ * @param rootDir - Root directory containing per-group subdirectories
434
+ * @param currentSafeName - Sanitized active task-group name to preserve
435
+ * @returns void
436
+ */
437
+ function cleanupStaleTaskGroupRoot(rootDir: string, currentSafeName: string | null): void {
438
+ if (!existsSync(rootDir)) return;
439
+ const now = Date.now();
440
+ for (const entry of readdirSync(rootDir, { withFileTypes: true })) {
441
+ if (!entry.isDirectory()) continue;
442
+ if (entry.name === currentSafeName) continue;
443
+
444
+ const taskGroupPath = join(rootDir, entry.name);
445
+ try {
446
+ // Check tasks/ subdir mtime — that's where writes happen.
447
+ const tasksPath = join(taskGroupPath, "tasks");
448
+ const target = existsSync(tasksPath) ? tasksPath : taskGroupPath;
449
+ const { mtimeMs } = statSync(target);
450
+ if (now - mtimeMs > TEAM_MAX_AGE_MS) {
451
+ rmSync(taskGroupPath, { recursive: true, force: true });
452
+ }
453
+ } catch {
454
+ // Skip individual failures (permissions, race conditions).
455
+ }
456
+ }
457
+ }
458
+
459
+ /**
460
+ * Remove task-group directories older than {@link TEAM_MAX_AGE_MS}.
461
+ *
462
+ * Skips the current task group (if any) to avoid deleting an active session.
463
+ * Runs once per session start. The legacy `~/.tallow/teams/` root is also
464
+ * cleaned so older builds do not leave permanent clutter behind.
393
465
  *
394
- * @param currentTeamName - The active team name to preserve, or null
466
+ * @param currentTeamName - The active shared task-group name to preserve, or null
395
467
  */
396
468
  export function cleanupStaleTeams(currentTeamName: string | null): void {
397
469
  try {
398
- if (!existsSync(TEAMS_DIR)) return;
399
- const now = Date.now();
400
470
  const currentSafeName = currentTeamName?.replace(/[^a-zA-Z0-9._-]/g, "_") ?? null;
401
-
402
- for (const entry of readdirSync(TEAMS_DIR, { withFileTypes: true })) {
403
- if (!entry.isDirectory()) continue;
404
- if (entry.name === currentSafeName) continue;
405
-
406
- const teamPath = join(TEAMS_DIR, entry.name);
407
- try {
408
- // Check tasks/ subdir mtime — that's where writes happen
409
- const tasksPath = join(teamPath, "tasks");
410
- const target = existsSync(tasksPath) ? tasksPath : teamPath;
411
- const { mtimeMs } = statSync(target);
412
- if (now - mtimeMs > TEAM_MAX_AGE_MS) {
413
- rmSync(teamPath, { recursive: true, force: true });
414
- }
415
- } catch {
416
- // Skip individual failures (permissions, race conditions)
417
- }
418
- }
471
+ cleanupStaleTaskGroupRoot(TASK_GROUPS_DIR, currentSafeName);
472
+ cleanupStaleTaskGroupRoot(LEGACY_TEAMS_DIR, currentSafeName);
419
473
  } catch {
420
- // TEAMS_DIR doesn't exist or isn't readable — nothing to clean
474
+ // Task-group roots don't exist or aren't readable — nothing to clean.
421
475
  }
422
476
  }
@@ -0,0 +1,98 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ deleteArchivedTeamFromDisk,
7
+ getTeamArchivesDir,
8
+ loadAllArchivedTeamsFromDisk,
9
+ loadArchivedTeamFromDisk,
10
+ writeArchivedTeamToDisk,
11
+ } from "../archive-store.js";
12
+ import {
13
+ addTaskToBoard,
14
+ addTeamMessage,
15
+ archiveTeam,
16
+ createTeamStore,
17
+ getArchivedTeams,
18
+ getTeams,
19
+ } from "../store.js";
20
+
21
+ let originalTallowHome: string | undefined;
22
+ let tmpHome: string;
23
+
24
+ beforeEach(() => {
25
+ originalTallowHome = process.env.TALLOW_CODING_AGENT_DIR;
26
+ tmpHome = mkdtempSync(join(tmpdir(), "tallow-team-archives-"));
27
+ process.env.TALLOW_CODING_AGENT_DIR = tmpHome;
28
+ getArchivedTeams().clear();
29
+ getTeams().clear();
30
+ });
31
+
32
+ afterEach(() => {
33
+ getArchivedTeams().clear();
34
+ getTeams().clear();
35
+ if (originalTallowHome === undefined) {
36
+ delete process.env.TALLOW_CODING_AGENT_DIR;
37
+ } else {
38
+ process.env.TALLOW_CODING_AGENT_DIR = originalTallowHome;
39
+ }
40
+ rmSync(tmpHome, { force: true, recursive: true });
41
+ });
42
+
43
+ describe("team archive persistence", () => {
44
+ it("writes and reloads archived teams from disk", () => {
45
+ const team = createTeamStore("alpha");
46
+ addTaskToBoard(team, "Investigate", "Read files", []);
47
+ const message = addTeamMessage(team, "alice", "bob", "hello");
48
+ message.readBy.add("bob");
49
+
50
+ const archived = archiveTeam("alpha");
51
+ expect(archived).toBeDefined();
52
+ if (!archived) return;
53
+
54
+ writeArchivedTeamToDisk(archived);
55
+
56
+ const loaded = loadArchivedTeamFromDisk("alpha");
57
+ expect(loaded).toBeDefined();
58
+ expect(loaded?.name).toBe("alpha");
59
+ expect(loaded?.tasks).toHaveLength(1);
60
+ expect(loaded?.messages).toHaveLength(1);
61
+ expect(loaded?.messages[0].readBy.has("bob")).toBe(true);
62
+ });
63
+
64
+ it("lists archives newest-first", () => {
65
+ const first = createTeamStore("first");
66
+ addTaskToBoard(first, "One", "", []);
67
+ const archivedFirst = archiveTeam("first");
68
+ expect(archivedFirst).toBeDefined();
69
+ if (!archivedFirst) return;
70
+ archivedFirst.archivedAt = 1;
71
+ writeArchivedTeamToDisk(archivedFirst);
72
+
73
+ const second = createTeamStore("second");
74
+ addTaskToBoard(second, "Two", "", []);
75
+ const archivedSecond = archiveTeam("second");
76
+ expect(archivedSecond).toBeDefined();
77
+ if (!archivedSecond) return;
78
+ archivedSecond.archivedAt = 2;
79
+ writeArchivedTeamToDisk(archivedSecond);
80
+
81
+ const names = loadAllArchivedTeamsFromDisk().map((archive) => archive.name);
82
+ expect(names).toEqual(["second", "first"]);
83
+ });
84
+
85
+ it("deletes persisted archives", () => {
86
+ createTeamStore("gone");
87
+ const archived = archiveTeam("gone");
88
+ expect(archived).toBeDefined();
89
+ if (!archived) return;
90
+ writeArchivedTeamToDisk(archived);
91
+ expect(loadArchivedTeamFromDisk("gone")?.name).toBe("gone");
92
+
93
+ deleteArchivedTeamFromDisk("gone");
94
+
95
+ expect(loadArchivedTeamFromDisk("gone")).toBeUndefined();
96
+ expect(getTeamArchivesDir().startsWith(tmpHome)).toBe(true);
97
+ });
98
+ });
@@ -258,4 +258,30 @@ describe("teammate task board operations", () => {
258
258
  expect(team.tasks[0].status).toBe("completed");
259
259
  expect(team.tasks[0].result).toBe("Found 388 files");
260
260
  });
261
+
262
+ it("rejects completing a task owned by another teammate", async () => {
263
+ const team = freshTeam();
264
+ const { mate: alice } = mockTeammate("alice");
265
+ const { mate: bob } = mockTeammate("bob");
266
+ team.teammates.set("alice", alice);
267
+ team.teammates.set("bob", bob);
268
+
269
+ const { addTaskToBoard } = await import("../store");
270
+ const task = addTaskToBoard(team, "Count files", "Count .ts files", []);
271
+ task.status = "claimed";
272
+ task.assignee = "alice";
273
+
274
+ const bobTools = createTeammateTools(team, "bob");
275
+ const tasksTool = findTool(bobTools, "team_tasks");
276
+ const result = await tasksTool.execute("c4", {
277
+ action: "complete",
278
+ taskId: "1",
279
+ result: "I should not be allowed",
280
+ });
281
+
282
+ expect(result.isError).toBe(true);
283
+ expect(result.content[0].text).toContain("Only the assignee can complete it");
284
+ expect(team.tasks[0].status).toBe("claimed");
285
+ expect(team.tasks[0].result).toBeNull();
286
+ });
261
287
  });