@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.
- package/dist/auth-hardening.d.ts +12 -0
- package/dist/auth-hardening.d.ts.map +1 -1
- package/dist/auth-hardening.js +30 -7
- package/dist/auth-hardening.js.map +1 -1
- package/dist/cli.js +5 -0
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.js +1 -1
- package/dist/install.js +2 -2
- package/dist/install.js.map +1 -1
- package/dist/interactive-mode-patch.d.ts.map +1 -1
- package/dist/interactive-mode-patch.js +119 -7
- package/dist/interactive-mode-patch.js.map +1 -1
- package/dist/model-metadata-overrides.d.ts +19 -0
- package/dist/model-metadata-overrides.d.ts.map +1 -0
- package/dist/model-metadata-overrides.js +38 -0
- package/dist/model-metadata-overrides.js.map +1 -0
- package/dist/sdk.d.ts +2 -0
- package/dist/sdk.d.ts.map +1 -1
- package/dist/sdk.js +28 -1
- package/dist/sdk.js.map +1 -1
- package/extensions/__integration__/teams-runtime.test.ts +22 -1
- package/extensions/_shared/__tests__/shell-policy.test.ts +197 -0
- package/extensions/_shared/shell-policy.ts +27 -0
- package/extensions/background-task-tool/index.ts +2 -1
- package/extensions/bash-tool-enhanced/index.ts +2 -1
- package/extensions/custom-footer/__tests__/index.test.ts +29 -0
- package/extensions/custom-footer/context-display.ts +49 -0
- package/extensions/custom-footer/index.ts +10 -23
- package/extensions/permissions/index.ts +31 -10
- package/extensions/plan-mode-tool/__tests__/index.test.ts +32 -2
- package/extensions/plan-mode-tool/index.ts +6 -1
- package/extensions/skill-commands/__tests__/shared-skills-dirs.test.ts +113 -0
- package/extensions/skill-commands/index.ts +62 -5
- package/extensions/slash-command-bridge/index.ts +30 -1
- package/extensions/subagent-tool/__tests__/process-liveness.test.ts +42 -3
- package/extensions/subagent-tool/process.ts +132 -21
- package/extensions/tasks/__tests__/store.test.ts +26 -2
- package/extensions/tasks/commands/register-tasks-extension.ts +2 -2
- package/extensions/tasks/index.ts +5 -5
- package/extensions/tasks/state/index.ts +90 -36
- package/extensions/teams-tool/__tests__/archive-store.test.ts +98 -0
- package/extensions/teams-tool/__tests__/peer-messaging.test.ts +26 -0
- package/extensions/teams-tool/archive-store.ts +200 -0
- package/extensions/teams-tool/sessions/spawn.ts +244 -71
- package/extensions/teams-tool/tools/register-extension.ts +146 -105
- package/extensions/teams-tool/tools/teammate-tools.ts +43 -1
- package/node_modules/@mariozechner/pi-tui/dist/keys.d.ts.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/keys.js +59 -7
- package/node_modules/@mariozechner/pi-tui/dist/keys.js.map +1 -1
- package/node_modules/@mariozechner/pi-tui/package.json +1 -1
- package/node_modules/@mariozechner/pi-tui/src/keys.ts +71 -7
- package/package.json +5 -5
- package/skills/tallow-expert/SKILL.md +1 -1
- package/templates/agents/architect.md +13 -5
- package/templates/agents/debug.md +3 -3
- package/templates/agents/explore.md +9 -2
- package/templates/agents/refactor.md +2 -2
- package/templates/agents/scout.md +3 -2
- 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:
|
|
485
|
+
inactivityTimeoutMs: 180_000,
|
|
472
486
|
killGraceMs: 5_000,
|
|
473
|
-
startupTimeoutMs:
|
|
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
|
-
|
|
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
|
|
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
|
|
556
|
-
:
|
|
557
|
-
|
|
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:
|
|
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
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
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(`
|
|
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
|
|
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
|
-
* -
|
|
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
|
-
* (
|
|
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
|
|
47
|
-
//
|
|
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 : `
|
|
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
|
|
30
|
-
export const
|
|
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
|
|
154
|
-
* one JSON file per
|
|
155
|
-
*
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
431
|
+
* Remove stale task-group directories from one root.
|
|
390
432
|
*
|
|
391
|
-
*
|
|
392
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
});
|