indus-swarms 0.1.0
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/AGENTS.md +14 -0
- package/LICENSE +21 -0
- package/README.md +119 -0
- package/docs/claude-parity.md +151 -0
- package/docs/field-notes-teams-setup.md +107 -0
- package/docs/smoke-test-plan.md +146 -0
- package/eslint.config.js +74 -0
- package/extensions/teams/README.md +23 -0
- package/extensions/teams/activity-tracker.ts +234 -0
- package/extensions/teams/cleanup.ts +31 -0
- package/extensions/teams/fs-lock.ts +87 -0
- package/extensions/teams/hooks.ts +363 -0
- package/extensions/teams/index.ts +18 -0
- package/extensions/teams/leader-attach-commands.ts +221 -0
- package/extensions/teams/leader-inbox.ts +214 -0
- package/extensions/teams/leader-info-commands.ts +140 -0
- package/extensions/teams/leader-lifecycle-commands.ts +559 -0
- package/extensions/teams/leader-messaging-commands.ts +148 -0
- package/extensions/teams/leader-plan-commands.ts +95 -0
- package/extensions/teams/leader-spawn-command.ts +149 -0
- package/extensions/teams/leader-task-commands.ts +435 -0
- package/extensions/teams/leader-team-command.ts +382 -0
- package/extensions/teams/leader-teams-tool.ts +1075 -0
- package/extensions/teams/leader.ts +925 -0
- package/extensions/teams/mailbox.ts +131 -0
- package/extensions/teams/model-policy.ts +142 -0
- package/extensions/teams/names.ts +121 -0
- package/extensions/teams/paths.ts +37 -0
- package/extensions/teams/protocol.ts +241 -0
- package/extensions/teams/spawn-types.ts +36 -0
- package/extensions/teams/task-store.ts +544 -0
- package/extensions/teams/team-attach-claim.ts +205 -0
- package/extensions/teams/team-config.ts +335 -0
- package/extensions/teams/team-discovery.ts +59 -0
- package/extensions/teams/teammate-rpc.ts +261 -0
- package/extensions/teams/teams-panel.ts +1186 -0
- package/extensions/teams/teams-style.ts +322 -0
- package/extensions/teams/teams-ui-shared.ts +89 -0
- package/extensions/teams/teams-widget.ts +212 -0
- package/extensions/teams/worker.ts +605 -0
- package/extensions/teams/worktree.ts +103 -0
- package/package.json +53 -0
- package/scripts/e2e-rpc-test.mjs +277 -0
- package/scripts/integration-claim-test.mts +157 -0
- package/scripts/integration-hooks-remediation-test.mts +382 -0
- package/scripts/integration-spawn-overrides-test.mts +398 -0
- package/scripts/integration-todo-test.mts +533 -0
- package/scripts/lib/pi-workers.ts +105 -0
- package/scripts/smoke-test.mts +764 -0
- package/scripts/start-tmux-team.sh +91 -0
- package/skills/agent-teams/SKILL.md +180 -0
- package/tsconfig.strict.json +22 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { withLock } from "./fs-lock.js";
|
|
4
|
+
|
|
5
|
+
export const TEAM_ATTACH_CLAIM_FILE = ".attach-claim.json";
|
|
6
|
+
export const TEAM_ATTACH_CLAIM_STALE_MS = 30_000;
|
|
7
|
+
|
|
8
|
+
export interface TeamAttachClaim {
|
|
9
|
+
holderSessionId: string;
|
|
10
|
+
claimedAt: string;
|
|
11
|
+
heartbeatAt: string;
|
|
12
|
+
pid: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
16
|
+
return typeof v === "object" && v !== null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function getString(obj: Record<string, unknown>, key: string): string | null {
|
|
20
|
+
const value = obj[key];
|
|
21
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getNumber(obj: Record<string, unknown>, key: string): number | null {
|
|
25
|
+
const value = obj[key];
|
|
26
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function parseTeamAttachClaim(value: unknown): TeamAttachClaim | null {
|
|
30
|
+
if (!isRecord(value)) return null;
|
|
31
|
+
const holderSessionId = getString(value, "holderSessionId");
|
|
32
|
+
const claimedAt = getString(value, "claimedAt");
|
|
33
|
+
const heartbeatAt = getString(value, "heartbeatAt");
|
|
34
|
+
const pid = getNumber(value, "pid");
|
|
35
|
+
if (!holderSessionId || !claimedAt || !heartbeatAt || pid === null) return null;
|
|
36
|
+
return { holderSessionId, claimedAt, heartbeatAt, pid };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function getClaimPath(teamDir: string): string {
|
|
40
|
+
return path.join(teamDir, TEAM_ATTACH_CLAIM_FILE);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function getClaimLockPath(teamDir: string): string {
|
|
44
|
+
return `${getClaimPath(teamDir)}.lock`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function readClaimUnchecked(teamDir: string): Promise<TeamAttachClaim | null> {
|
|
48
|
+
const file = getClaimPath(teamDir);
|
|
49
|
+
try {
|
|
50
|
+
const raw = await fs.promises.readFile(file, "utf8");
|
|
51
|
+
const parsed: unknown = JSON.parse(raw);
|
|
52
|
+
return parseTeamAttachClaim(parsed);
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function writeClaimAtomic(teamDir: string, claim: TeamAttachClaim): Promise<void> {
|
|
59
|
+
const file = getClaimPath(teamDir);
|
|
60
|
+
await fs.promises.mkdir(teamDir, { recursive: true });
|
|
61
|
+
const tmp = `${file}.tmp.${process.pid}.${Date.now()}`;
|
|
62
|
+
await fs.promises.writeFile(tmp, JSON.stringify(claim, null, 2) + "\n", "utf8");
|
|
63
|
+
await fs.promises.rename(tmp, file);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function assessAttachClaimFreshness(claim: TeamAttachClaim, nowMs = Date.now(), staleMs = TEAM_ATTACH_CLAIM_STALE_MS): {
|
|
67
|
+
isStale: boolean;
|
|
68
|
+
ageMs: number;
|
|
69
|
+
} {
|
|
70
|
+
const heartbeatMs = Date.parse(claim.heartbeatAt);
|
|
71
|
+
if (!Number.isFinite(heartbeatMs)) {
|
|
72
|
+
return { isStale: true, ageMs: Number.POSITIVE_INFINITY };
|
|
73
|
+
}
|
|
74
|
+
const ageMs = Math.max(0, nowMs - heartbeatMs);
|
|
75
|
+
return {
|
|
76
|
+
isStale: ageMs > staleMs,
|
|
77
|
+
ageMs,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function readTeamAttachClaim(teamDir: string): Promise<TeamAttachClaim | null> {
|
|
82
|
+
return await readClaimUnchecked(teamDir);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type AcquireTeamAttachClaimResult =
|
|
86
|
+
| {
|
|
87
|
+
ok: true;
|
|
88
|
+
claim: TeamAttachClaim;
|
|
89
|
+
replacedClaim?: TeamAttachClaim;
|
|
90
|
+
}
|
|
91
|
+
| {
|
|
92
|
+
ok: false;
|
|
93
|
+
reason: "claimed_by_other";
|
|
94
|
+
claim: TeamAttachClaim;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export async function acquireTeamAttachClaim(
|
|
98
|
+
teamDir: string,
|
|
99
|
+
holderSessionId: string,
|
|
100
|
+
opts?: { force?: boolean; staleMs?: number; nowMs?: number },
|
|
101
|
+
): Promise<AcquireTeamAttachClaimResult> {
|
|
102
|
+
const lockFile = getClaimLockPath(teamDir);
|
|
103
|
+
const staleMs = opts?.staleMs ?? TEAM_ATTACH_CLAIM_STALE_MS;
|
|
104
|
+
const force = opts?.force === true;
|
|
105
|
+
|
|
106
|
+
await fs.promises.mkdir(teamDir, { recursive: true });
|
|
107
|
+
|
|
108
|
+
return await withLock(
|
|
109
|
+
lockFile,
|
|
110
|
+
async () => {
|
|
111
|
+
const nowMs = opts?.nowMs ?? Date.now();
|
|
112
|
+
const nowIso = new Date(nowMs).toISOString();
|
|
113
|
+
const current = await readClaimUnchecked(teamDir);
|
|
114
|
+
if (current) {
|
|
115
|
+
const freshness = assessAttachClaimFreshness(current, nowMs, staleMs);
|
|
116
|
+
const sameHolder = current.holderSessionId === holderSessionId;
|
|
117
|
+
if (!sameHolder && !freshness.isStale && !force) {
|
|
118
|
+
return { ok: false, reason: "claimed_by_other", claim: current };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const claim: TeamAttachClaim = {
|
|
122
|
+
holderSessionId,
|
|
123
|
+
claimedAt: sameHolder ? current.claimedAt : nowIso,
|
|
124
|
+
heartbeatAt: nowIso,
|
|
125
|
+
pid: process.pid,
|
|
126
|
+
};
|
|
127
|
+
await writeClaimAtomic(teamDir, claim);
|
|
128
|
+
return {
|
|
129
|
+
ok: true,
|
|
130
|
+
claim,
|
|
131
|
+
replacedClaim: sameHolder ? undefined : current,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const claim: TeamAttachClaim = {
|
|
136
|
+
holderSessionId,
|
|
137
|
+
claimedAt: nowIso,
|
|
138
|
+
heartbeatAt: nowIso,
|
|
139
|
+
pid: process.pid,
|
|
140
|
+
};
|
|
141
|
+
await writeClaimAtomic(teamDir, claim);
|
|
142
|
+
return { ok: true, claim };
|
|
143
|
+
},
|
|
144
|
+
{ label: `team-attach-claim:acquire:${holderSessionId}` },
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export type TeamAttachClaimHeartbeatResult = "updated" | "not_owner" | "missing";
|
|
149
|
+
|
|
150
|
+
export async function heartbeatTeamAttachClaim(
|
|
151
|
+
teamDir: string,
|
|
152
|
+
holderSessionId: string,
|
|
153
|
+
opts?: { nowMs?: number },
|
|
154
|
+
): Promise<TeamAttachClaimHeartbeatResult> {
|
|
155
|
+
const lockFile = getClaimLockPath(teamDir);
|
|
156
|
+
await fs.promises.mkdir(teamDir, { recursive: true });
|
|
157
|
+
|
|
158
|
+
return await withLock(
|
|
159
|
+
lockFile,
|
|
160
|
+
async () => {
|
|
161
|
+
const current = await readClaimUnchecked(teamDir);
|
|
162
|
+
if (!current) return "missing";
|
|
163
|
+
if (current.holderSessionId !== holderSessionId) return "not_owner";
|
|
164
|
+
const nowMs = opts?.nowMs ?? Date.now();
|
|
165
|
+
const nowIso = new Date(nowMs).toISOString();
|
|
166
|
+
const updated: TeamAttachClaim = {
|
|
167
|
+
...current,
|
|
168
|
+
heartbeatAt: nowIso,
|
|
169
|
+
pid: process.pid,
|
|
170
|
+
};
|
|
171
|
+
await writeClaimAtomic(teamDir, updated);
|
|
172
|
+
return "updated";
|
|
173
|
+
},
|
|
174
|
+
{ label: `team-attach-claim:heartbeat:${holderSessionId}` },
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export type TeamAttachClaimReleaseResult = "released" | "not_owner" | "none";
|
|
179
|
+
|
|
180
|
+
export async function releaseTeamAttachClaim(
|
|
181
|
+
teamDir: string,
|
|
182
|
+
holderSessionId: string,
|
|
183
|
+
opts?: { force?: boolean },
|
|
184
|
+
): Promise<TeamAttachClaimReleaseResult> {
|
|
185
|
+
const lockFile = getClaimLockPath(teamDir);
|
|
186
|
+
const file = getClaimPath(teamDir);
|
|
187
|
+
const force = opts?.force === true;
|
|
188
|
+
await fs.promises.mkdir(teamDir, { recursive: true });
|
|
189
|
+
|
|
190
|
+
return await withLock(
|
|
191
|
+
lockFile,
|
|
192
|
+
async () => {
|
|
193
|
+
const current = await readClaimUnchecked(teamDir);
|
|
194
|
+
if (!current) return "none";
|
|
195
|
+
if (!force && current.holderSessionId !== holderSessionId) return "not_owner";
|
|
196
|
+
try {
|
|
197
|
+
await fs.promises.unlink(file);
|
|
198
|
+
} catch {
|
|
199
|
+
// ignore: treat as released best effort
|
|
200
|
+
}
|
|
201
|
+
return "released";
|
|
202
|
+
},
|
|
203
|
+
{ label: `team-attach-claim:release:${holderSessionId}` },
|
|
204
|
+
);
|
|
205
|
+
}
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { withLock } from "./fs-lock.js";
|
|
4
|
+
import { sanitizeName } from "./names.js";
|
|
5
|
+
import type { TeamsStyle } from "./teams-style.js";
|
|
6
|
+
import { normalizeTeamsStyleId } from "./teams-style.js";
|
|
7
|
+
|
|
8
|
+
export interface TeamMember {
|
|
9
|
+
name: string;
|
|
10
|
+
role: "lead" | "worker";
|
|
11
|
+
addedAt: string;
|
|
12
|
+
status: "online" | "offline";
|
|
13
|
+
lastSeenAt?: string;
|
|
14
|
+
/** Optional: teammate session file path (useful for debugging) */
|
|
15
|
+
sessionFile?: string;
|
|
16
|
+
/** Optional: teammate working directory */
|
|
17
|
+
cwd?: string;
|
|
18
|
+
/** Freeform metadata for future use */
|
|
19
|
+
meta?: Record<string, unknown>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type TeamHooksFailureAction = "warn" | "followup" | "reopen" | "reopen_followup";
|
|
23
|
+
export type TeamHooksFollowupOwnerPolicy = "member" | "lead" | "none";
|
|
24
|
+
|
|
25
|
+
export interface TeamHooksPolicy {
|
|
26
|
+
failureAction?: TeamHooksFailureAction;
|
|
27
|
+
maxReopensPerTask?: number;
|
|
28
|
+
followupOwner?: TeamHooksFollowupOwnerPolicy;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface TeamConfig {
|
|
32
|
+
version: 1;
|
|
33
|
+
teamId: string;
|
|
34
|
+
/** Task list namespace identifier (Claude-style: often teamName or parentSessionId) */
|
|
35
|
+
taskListId: string;
|
|
36
|
+
/** Internal leader agent id (mailbox name, member name, etc.) */
|
|
37
|
+
leadName: string;
|
|
38
|
+
/** Optional UI/UX style. If omitted, treat as "normal". */
|
|
39
|
+
style?: TeamsStyle;
|
|
40
|
+
/** Optional per-team hooks policy override (env remains fallback). */
|
|
41
|
+
hooks?: TeamHooksPolicy;
|
|
42
|
+
createdAt: string;
|
|
43
|
+
updatedAt: string;
|
|
44
|
+
members: TeamMember[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function getTeamConfigPath(teamDir: string): string {
|
|
48
|
+
return path.join(teamDir, "config.json");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function ensureDir(p: string): Promise<void> {
|
|
52
|
+
await fs.promises.mkdir(p, { recursive: true });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
56
|
+
return typeof v === "object" && v !== null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function coerceStyle(v: unknown): TeamsStyle | undefined {
|
|
60
|
+
return normalizeTeamsStyleId(v) ?? undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isTeamHooksFailureAction(v: unknown): v is TeamHooksFailureAction {
|
|
64
|
+
return v === "warn" || v === "followup" || v === "reopen" || v === "reopen_followup";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isTeamHooksFollowupOwnerPolicy(v: unknown): v is TeamHooksFollowupOwnerPolicy {
|
|
68
|
+
return v === "member" || v === "lead" || v === "none";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function coerceNonNegativeInt(v: unknown): number | undefined {
|
|
72
|
+
if (typeof v !== "number" || !Number.isFinite(v) || v < 0) return undefined;
|
|
73
|
+
return Math.floor(v);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function coerceHooksPolicy(v: unknown): TeamHooksPolicy | undefined {
|
|
77
|
+
if (!isRecord(v)) return undefined;
|
|
78
|
+
const failureAction = isTeamHooksFailureAction(v.failureAction) ? v.failureAction : undefined;
|
|
79
|
+
const maxReopensPerTask = coerceNonNegativeInt(v.maxReopensPerTask);
|
|
80
|
+
const followupOwner = isTeamHooksFollowupOwnerPolicy(v.followupOwner) ? v.followupOwner : undefined;
|
|
81
|
+
if (!failureAction && maxReopensPerTask === undefined && !followupOwner) return undefined;
|
|
82
|
+
return { failureAction, maxReopensPerTask, followupOwner };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function readJson(file: string): Promise<unknown | null> {
|
|
86
|
+
try {
|
|
87
|
+
const raw = await fs.promises.readFile(file, "utf8");
|
|
88
|
+
const parsed: unknown = JSON.parse(raw);
|
|
89
|
+
return parsed;
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function writeJsonAtomic(file: string, data: unknown): Promise<void> {
|
|
96
|
+
await ensureDir(path.dirname(file));
|
|
97
|
+
const tmp = `${file}.tmp.${process.pid}.${Date.now()}`;
|
|
98
|
+
await fs.promises.writeFile(tmp, JSON.stringify(data, null, 2) + "\n", "utf8");
|
|
99
|
+
await fs.promises.rename(tmp, file);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function coerceMember(obj: unknown): TeamMember | null {
|
|
103
|
+
if (!isRecord(obj)) return null;
|
|
104
|
+
if (typeof obj.name !== "string") return null;
|
|
105
|
+
if (obj.role !== "lead" && obj.role !== "worker") return null;
|
|
106
|
+
if (obj.status !== "online" && obj.status !== "offline") return null;
|
|
107
|
+
if (typeof obj.addedAt !== "string") return null;
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
name: sanitizeName(obj.name),
|
|
111
|
+
role: obj.role,
|
|
112
|
+
status: obj.status,
|
|
113
|
+
addedAt: obj.addedAt,
|
|
114
|
+
lastSeenAt: typeof obj.lastSeenAt === "string" ? obj.lastSeenAt : undefined,
|
|
115
|
+
sessionFile: typeof obj.sessionFile === "string" ? obj.sessionFile : undefined,
|
|
116
|
+
cwd: typeof obj.cwd === "string" ? obj.cwd : undefined,
|
|
117
|
+
meta: isRecord(obj.meta) ? obj.meta : undefined,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function coerceConfig(obj: unknown): TeamConfig | null {
|
|
122
|
+
if (!isRecord(obj)) return null;
|
|
123
|
+
if (obj.version !== 1) return null;
|
|
124
|
+
if (typeof obj.teamId !== "string") return null;
|
|
125
|
+
if (typeof obj.taskListId !== "string") return null;
|
|
126
|
+
if (typeof obj.leadName !== "string") return null;
|
|
127
|
+
if (typeof obj.createdAt !== "string") return null;
|
|
128
|
+
if (typeof obj.updatedAt !== "string") return null;
|
|
129
|
+
if (!Array.isArray(obj.members)) return null;
|
|
130
|
+
|
|
131
|
+
const style = coerceStyle(obj.style);
|
|
132
|
+
const hooks = coerceHooksPolicy(obj.hooks);
|
|
133
|
+
const members = obj.members.map(coerceMember).filter((m): m is TeamMember => m !== null);
|
|
134
|
+
return {
|
|
135
|
+
version: 1,
|
|
136
|
+
teamId: obj.teamId,
|
|
137
|
+
taskListId: obj.taskListId,
|
|
138
|
+
leadName: sanitizeName(obj.leadName),
|
|
139
|
+
style,
|
|
140
|
+
hooks,
|
|
141
|
+
createdAt: obj.createdAt,
|
|
142
|
+
updatedAt: obj.updatedAt,
|
|
143
|
+
members,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function loadTeamConfig(teamDir: string): Promise<TeamConfig | null> {
|
|
148
|
+
const file = getTeamConfigPath(teamDir);
|
|
149
|
+
const obj = await readJson(file);
|
|
150
|
+
return coerceConfig(obj);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function ensureTeamConfig(
|
|
154
|
+
teamDir: string,
|
|
155
|
+
init: { teamId: string; taskListId: string; leadName: string; style?: TeamsStyle },
|
|
156
|
+
): Promise<TeamConfig> {
|
|
157
|
+
const file = getTeamConfigPath(teamDir);
|
|
158
|
+
const lock = `${file}.lock`;
|
|
159
|
+
|
|
160
|
+
await ensureDir(teamDir);
|
|
161
|
+
|
|
162
|
+
return await withLock(
|
|
163
|
+
lock,
|
|
164
|
+
async () => {
|
|
165
|
+
const existing = coerceConfig(await readJson(file));
|
|
166
|
+
if (existing) return existing;
|
|
167
|
+
|
|
168
|
+
const now = new Date().toISOString();
|
|
169
|
+
const cfg: TeamConfig = {
|
|
170
|
+
version: 1,
|
|
171
|
+
teamId: init.teamId,
|
|
172
|
+
taskListId: init.taskListId,
|
|
173
|
+
leadName: sanitizeName(init.leadName),
|
|
174
|
+
style: init.style ?? "normal",
|
|
175
|
+
createdAt: now,
|
|
176
|
+
updatedAt: now,
|
|
177
|
+
members: [
|
|
178
|
+
{
|
|
179
|
+
name: sanitizeName(init.leadName),
|
|
180
|
+
role: "lead",
|
|
181
|
+
addedAt: now,
|
|
182
|
+
status: "online",
|
|
183
|
+
lastSeenAt: now,
|
|
184
|
+
},
|
|
185
|
+
],
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
await writeJsonAtomic(file, cfg);
|
|
189
|
+
return cfg;
|
|
190
|
+
},
|
|
191
|
+
{ label: "team-config:ensure" },
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function setTeamStyle(teamDir: string, style: TeamsStyle): Promise<TeamConfig | null> {
|
|
196
|
+
const file = getTeamConfigPath(teamDir);
|
|
197
|
+
const lock = `${file}.lock`;
|
|
198
|
+
|
|
199
|
+
await ensureDir(teamDir);
|
|
200
|
+
|
|
201
|
+
return await withLock(
|
|
202
|
+
lock,
|
|
203
|
+
async () => {
|
|
204
|
+
const existing = coerceConfig(await readJson(file));
|
|
205
|
+
if (!existing) return null;
|
|
206
|
+
|
|
207
|
+
const now = new Date().toISOString();
|
|
208
|
+
const updated: TeamConfig = { ...existing, style, updatedAt: now };
|
|
209
|
+
await writeJsonAtomic(file, updated);
|
|
210
|
+
return updated;
|
|
211
|
+
},
|
|
212
|
+
{ label: `team-config:style:${style}` },
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export async function updateTeamHooksPolicy(
|
|
217
|
+
teamDir: string,
|
|
218
|
+
updater: (current: TeamHooksPolicy) => TeamHooksPolicy | undefined,
|
|
219
|
+
): Promise<TeamConfig | null> {
|
|
220
|
+
const file = getTeamConfigPath(teamDir);
|
|
221
|
+
const lock = `${file}.lock`;
|
|
222
|
+
|
|
223
|
+
await ensureDir(teamDir);
|
|
224
|
+
|
|
225
|
+
return await withLock(
|
|
226
|
+
lock,
|
|
227
|
+
async () => {
|
|
228
|
+
const existing = coerceConfig(await readJson(file));
|
|
229
|
+
if (!existing) return null;
|
|
230
|
+
|
|
231
|
+
const nextHooks = coerceHooksPolicy(updater(existing.hooks ?? {}));
|
|
232
|
+
const now = new Date().toISOString();
|
|
233
|
+
const updated: TeamConfig = { ...existing, hooks: nextHooks, updatedAt: now };
|
|
234
|
+
await writeJsonAtomic(file, updated);
|
|
235
|
+
return updated;
|
|
236
|
+
},
|
|
237
|
+
{ label: "team-config:hooks-policy" },
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export async function upsertMember(
|
|
242
|
+
teamDir: string,
|
|
243
|
+
member: Omit<TeamMember, "name" | "addedAt"> & { name: string; addedAt?: string },
|
|
244
|
+
): Promise<TeamConfig> {
|
|
245
|
+
const file = getTeamConfigPath(teamDir);
|
|
246
|
+
const lock = `${file}.lock`;
|
|
247
|
+
|
|
248
|
+
await ensureDir(teamDir);
|
|
249
|
+
|
|
250
|
+
return await withLock(
|
|
251
|
+
lock,
|
|
252
|
+
async () => {
|
|
253
|
+
const existing = coerceConfig(await readJson(file));
|
|
254
|
+
if (!existing) {
|
|
255
|
+
throw new Error(`Team config missing. Call ensureTeamConfig() first. path=${file}`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const now = new Date().toISOString();
|
|
259
|
+
const name = sanitizeName(member.name);
|
|
260
|
+
const idx = existing.members.findIndex((m) => m.name === name);
|
|
261
|
+
const prev = idx >= 0 ? existing.members[idx] : undefined;
|
|
262
|
+
if (idx >= 0 && !prev) {
|
|
263
|
+
throw new Error(`Team config corrupted: member index out of range. idx=${idx}`);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const nextMember: TeamMember = {
|
|
267
|
+
name,
|
|
268
|
+
role: member.role,
|
|
269
|
+
status: member.status,
|
|
270
|
+
addedAt: prev ? prev.addedAt : member.addedAt ?? now,
|
|
271
|
+
lastSeenAt: member.lastSeenAt ?? now,
|
|
272
|
+
sessionFile: member.sessionFile,
|
|
273
|
+
cwd: member.cwd,
|
|
274
|
+
meta: member.meta,
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
const members = existing.members.slice();
|
|
278
|
+
if (prev) {
|
|
279
|
+
members[idx] = { ...prev, ...nextMember, addedAt: prev.addedAt };
|
|
280
|
+
} else {
|
|
281
|
+
members.push(nextMember);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const updated: TeamConfig = {
|
|
285
|
+
...existing,
|
|
286
|
+
updatedAt: now,
|
|
287
|
+
members,
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
await writeJsonAtomic(file, updated);
|
|
291
|
+
return updated;
|
|
292
|
+
},
|
|
293
|
+
{ label: `team-config:upsert:${member.name}` },
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export async function setMemberStatus(
|
|
298
|
+
teamDir: string,
|
|
299
|
+
memberName: string,
|
|
300
|
+
status: "online" | "offline",
|
|
301
|
+
extra?: { lastSeenAt?: string; meta?: Record<string, unknown> },
|
|
302
|
+
): Promise<TeamConfig | null> {
|
|
303
|
+
const file = getTeamConfigPath(teamDir);
|
|
304
|
+
const lock = `${file}.lock`;
|
|
305
|
+
|
|
306
|
+
await ensureDir(teamDir);
|
|
307
|
+
|
|
308
|
+
return await withLock(
|
|
309
|
+
lock,
|
|
310
|
+
async () => {
|
|
311
|
+
const existing = coerceConfig(await readJson(file));
|
|
312
|
+
if (!existing) return null;
|
|
313
|
+
|
|
314
|
+
const name = sanitizeName(memberName);
|
|
315
|
+
const idx = existing.members.findIndex((m) => m.name === name);
|
|
316
|
+
if (idx < 0) return existing;
|
|
317
|
+
|
|
318
|
+
const now = new Date().toISOString();
|
|
319
|
+
const members = existing.members.slice();
|
|
320
|
+
const prev = members[idx];
|
|
321
|
+
if (!prev) return existing;
|
|
322
|
+
members[idx] = {
|
|
323
|
+
...prev,
|
|
324
|
+
status,
|
|
325
|
+
lastSeenAt: extra?.lastSeenAt ?? now,
|
|
326
|
+
meta: extra?.meta ? { ...(prev.meta ?? {}), ...extra.meta } : prev.meta,
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
const updated: TeamConfig = { ...existing, updatedAt: now, members };
|
|
330
|
+
await writeJsonAtomic(file, updated);
|
|
331
|
+
return updated;
|
|
332
|
+
},
|
|
333
|
+
{ label: `team-config:status:${memberName}` },
|
|
334
|
+
);
|
|
335
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { getTeamsRootDir } from "./paths.js";
|
|
4
|
+
import { assessAttachClaimFreshness, readTeamAttachClaim } from "./team-attach-claim.js";
|
|
5
|
+
import { loadTeamConfig } from "./team-config.js";
|
|
6
|
+
import type { TeamsStyle } from "./teams-style.js";
|
|
7
|
+
|
|
8
|
+
export interface DiscoveredTeam {
|
|
9
|
+
teamId: string;
|
|
10
|
+
teamDir: string;
|
|
11
|
+
taskListId: string;
|
|
12
|
+
leadName: string;
|
|
13
|
+
style: TeamsStyle;
|
|
14
|
+
workerCount: number;
|
|
15
|
+
onlineWorkerCount: number;
|
|
16
|
+
updatedAt: string;
|
|
17
|
+
attachedBySessionId?: string;
|
|
18
|
+
attachClaimStale?: boolean;
|
|
19
|
+
attachHeartbeatAt?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function listDiscoveredTeams(teamsRoot = getTeamsRootDir()): Promise<DiscoveredTeam[]> {
|
|
23
|
+
let entries: fs.Dirent[];
|
|
24
|
+
try {
|
|
25
|
+
entries = await fs.promises.readdir(teamsRoot, { withFileTypes: true });
|
|
26
|
+
} catch {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const out: DiscoveredTeam[] = [];
|
|
31
|
+
for (const e of entries) {
|
|
32
|
+
if (!e.isDirectory()) continue;
|
|
33
|
+
if (e.name.startsWith("_")) continue;
|
|
34
|
+
const teamDir = path.join(teamsRoot, e.name);
|
|
35
|
+
const cfg = await loadTeamConfig(teamDir);
|
|
36
|
+
if (!cfg) continue;
|
|
37
|
+
|
|
38
|
+
const workers = cfg.members.filter((m) => m.role === "worker");
|
|
39
|
+
const onlineWorkerCount = workers.filter((m) => m.status === "online").length;
|
|
40
|
+
const attachClaim = await readTeamAttachClaim(teamDir);
|
|
41
|
+
const freshness = attachClaim ? assessAttachClaimFreshness(attachClaim) : null;
|
|
42
|
+
out.push({
|
|
43
|
+
teamId: cfg.teamId,
|
|
44
|
+
teamDir,
|
|
45
|
+
taskListId: cfg.taskListId,
|
|
46
|
+
leadName: cfg.leadName,
|
|
47
|
+
style: cfg.style ?? "normal",
|
|
48
|
+
workerCount: workers.length,
|
|
49
|
+
onlineWorkerCount,
|
|
50
|
+
updatedAt: cfg.updatedAt,
|
|
51
|
+
attachedBySessionId: attachClaim?.holderSessionId,
|
|
52
|
+
attachClaimStale: freshness?.isStale,
|
|
53
|
+
attachHeartbeatAt: attachClaim?.heartbeatAt,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
out.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : a.updatedAt > b.updatedAt ? -1 : 0));
|
|
58
|
+
return out;
|
|
59
|
+
}
|