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.
Files changed (52) hide show
  1. package/AGENTS.md +14 -0
  2. package/LICENSE +21 -0
  3. package/README.md +119 -0
  4. package/docs/claude-parity.md +151 -0
  5. package/docs/field-notes-teams-setup.md +107 -0
  6. package/docs/smoke-test-plan.md +146 -0
  7. package/eslint.config.js +74 -0
  8. package/extensions/teams/README.md +23 -0
  9. package/extensions/teams/activity-tracker.ts +234 -0
  10. package/extensions/teams/cleanup.ts +31 -0
  11. package/extensions/teams/fs-lock.ts +87 -0
  12. package/extensions/teams/hooks.ts +363 -0
  13. package/extensions/teams/index.ts +18 -0
  14. package/extensions/teams/leader-attach-commands.ts +221 -0
  15. package/extensions/teams/leader-inbox.ts +214 -0
  16. package/extensions/teams/leader-info-commands.ts +140 -0
  17. package/extensions/teams/leader-lifecycle-commands.ts +559 -0
  18. package/extensions/teams/leader-messaging-commands.ts +148 -0
  19. package/extensions/teams/leader-plan-commands.ts +95 -0
  20. package/extensions/teams/leader-spawn-command.ts +149 -0
  21. package/extensions/teams/leader-task-commands.ts +435 -0
  22. package/extensions/teams/leader-team-command.ts +382 -0
  23. package/extensions/teams/leader-teams-tool.ts +1075 -0
  24. package/extensions/teams/leader.ts +925 -0
  25. package/extensions/teams/mailbox.ts +131 -0
  26. package/extensions/teams/model-policy.ts +142 -0
  27. package/extensions/teams/names.ts +121 -0
  28. package/extensions/teams/paths.ts +37 -0
  29. package/extensions/teams/protocol.ts +241 -0
  30. package/extensions/teams/spawn-types.ts +36 -0
  31. package/extensions/teams/task-store.ts +544 -0
  32. package/extensions/teams/team-attach-claim.ts +205 -0
  33. package/extensions/teams/team-config.ts +335 -0
  34. package/extensions/teams/team-discovery.ts +59 -0
  35. package/extensions/teams/teammate-rpc.ts +261 -0
  36. package/extensions/teams/teams-panel.ts +1186 -0
  37. package/extensions/teams/teams-style.ts +322 -0
  38. package/extensions/teams/teams-ui-shared.ts +89 -0
  39. package/extensions/teams/teams-widget.ts +212 -0
  40. package/extensions/teams/worker.ts +605 -0
  41. package/extensions/teams/worktree.ts +103 -0
  42. package/package.json +53 -0
  43. package/scripts/e2e-rpc-test.mjs +277 -0
  44. package/scripts/integration-claim-test.mts +157 -0
  45. package/scripts/integration-hooks-remediation-test.mts +382 -0
  46. package/scripts/integration-spawn-overrides-test.mts +398 -0
  47. package/scripts/integration-todo-test.mts +533 -0
  48. package/scripts/lib/pi-workers.ts +105 -0
  49. package/scripts/smoke-test.mts +764 -0
  50. package/scripts/start-tmux-team.sh +91 -0
  51. package/skills/agent-teams/SKILL.md +180 -0
  52. package/tsconfig.strict.json +22 -0
@@ -0,0 +1,131 @@
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
+
6
+ export interface MailboxMessage {
7
+ from: string;
8
+ text: string;
9
+ timestamp: string;
10
+ read: boolean;
11
+ color?: string;
12
+ }
13
+
14
+ function inboxDir(teamDir: string, namespace: string): string {
15
+ return path.join(teamDir, "mailboxes", sanitizeName(namespace), "inboxes");
16
+ }
17
+
18
+ export function getInboxPath(teamDir: string, namespace: string, agentName: string): string {
19
+ return path.join(inboxDir(teamDir, namespace), `${sanitizeName(agentName)}.json`);
20
+ }
21
+
22
+ async function ensureDir(p: string): Promise<void> {
23
+ await fs.promises.mkdir(p, { recursive: true });
24
+ }
25
+
26
+ function isRecord(v: unknown): v is Record<string, unknown> {
27
+ return typeof v === "object" && v !== null;
28
+ }
29
+
30
+ function isLockTimeoutError(err: unknown): err is Error {
31
+ return err instanceof Error && err.message.startsWith("Timeout acquiring lock:");
32
+ }
33
+
34
+ function coerceMailboxMessage(v: unknown): MailboxMessage | null {
35
+ if (!isRecord(v)) return null;
36
+ if (typeof v.from !== "string") return null;
37
+ if (typeof v.text !== "string") return null;
38
+ if (typeof v.timestamp !== "string") return null;
39
+ const read = typeof v.read === "boolean" ? v.read : false;
40
+ const color = typeof v.color === "string" ? v.color : undefined;
41
+ return { from: v.from, text: v.text, timestamp: v.timestamp, read, color };
42
+ }
43
+
44
+ async function readJsonArray(file: string): Promise<unknown[]> {
45
+ try {
46
+ const raw = await fs.promises.readFile(file, "utf8");
47
+ const parsed: unknown = JSON.parse(raw);
48
+ return Array.isArray(parsed) ? parsed : [];
49
+ } catch {
50
+ return [];
51
+ }
52
+ }
53
+
54
+ async function writeJsonAtomic(file: string, data: unknown): Promise<void> {
55
+ await ensureDir(path.dirname(file));
56
+ const tmp = `${file}.tmp.${process.pid}.${Date.now()}`;
57
+ await fs.promises.writeFile(tmp, JSON.stringify(data, null, 2) + "\n", "utf8");
58
+ await fs.promises.rename(tmp, file);
59
+ }
60
+
61
+ /** Append a message to an agent's inbox. */
62
+ export async function writeToMailbox(
63
+ teamDir: string,
64
+ namespace: string,
65
+ recipient: string,
66
+ msg: Omit<MailboxMessage, "read"> & { read?: boolean },
67
+ ): Promise<void> {
68
+ const inboxPath = getInboxPath(teamDir, namespace, recipient);
69
+ const lockPath = `${inboxPath}.lock`;
70
+
71
+ await ensureDir(path.dirname(inboxPath));
72
+
73
+ await withLock(
74
+ lockPath,
75
+ async () => {
76
+ const arr = await readJsonArray(inboxPath);
77
+ const m: MailboxMessage = {
78
+ from: msg.from,
79
+ text: msg.text,
80
+ timestamp: msg.timestamp,
81
+ read: msg.read ?? false,
82
+ color: msg.color,
83
+ };
84
+ arr.push(m);
85
+ await writeJsonAtomic(inboxPath, arr);
86
+ },
87
+ { label: `mailbox:write:${namespace}:${recipient}` },
88
+ );
89
+ }
90
+
91
+ /**
92
+ * Read unread messages and mark them as read in a single locked transaction.
93
+ * This is the worker/leader poll primitive.
94
+ */
95
+ export async function popUnreadMessages(teamDir: string, namespace: string, agentName: string): Promise<MailboxMessage[]> {
96
+ const inboxPath = getInboxPath(teamDir, namespace, agentName);
97
+ const lockPath = `${inboxPath}.lock`;
98
+
99
+ await ensureDir(path.dirname(inboxPath));
100
+
101
+ try {
102
+ return await withLock(
103
+ lockPath,
104
+ async () => {
105
+ const arr = (await readJsonArray(inboxPath))
106
+ .map(coerceMailboxMessage)
107
+ .filter((m): m is MailboxMessage => m !== null);
108
+ if (arr.length === 0) return [];
109
+
110
+ const unread: MailboxMessage[] = [];
111
+ const updated = arr.map((m) => {
112
+ if (!m.read) {
113
+ const next = { ...m, read: true };
114
+ unread.push(next);
115
+ return next;
116
+ }
117
+ return m;
118
+ });
119
+
120
+ if (unread.length) await writeJsonAtomic(inboxPath, updated);
121
+ return unread;
122
+ },
123
+ { label: `mailbox:pop:${namespace}:${agentName}` },
124
+ );
125
+ } catch (err: unknown) {
126
+ // In practice this can happen if a previous process crashed and left a non-stale
127
+ // lockfile behind. Treat as transient and try again on the next poll tick.
128
+ if (isLockTimeoutError(err)) return [];
129
+ throw err;
130
+ }
131
+ }
@@ -0,0 +1,142 @@
1
+ const SONNET4_DEPRECATED_MARKER = "claude-sonnet-4";
2
+ const SONNET45_ALLOWED_MARKERS = ["claude-sonnet-4-5", "claude-sonnet-4.5"];
3
+
4
+ function normalizeModelId(modelId: string): string {
5
+ return modelId.trim().toLowerCase();
6
+ }
7
+
8
+ function hasAnyMarker(value: string, markers: readonly string[]): boolean {
9
+ for (const marker of markers) {
10
+ if (value.includes(marker)) return true;
11
+ }
12
+ return false;
13
+ }
14
+
15
+ function trimOrUndefined(value: string | undefined): string | undefined {
16
+ if (value === undefined) return undefined;
17
+ const trimmed = value.trim();
18
+ return trimmed.length > 0 ? trimmed : undefined;
19
+ }
20
+
21
+ export function isDeprecatedTeammateModelId(modelId: string): boolean {
22
+ const normalized = normalizeModelId(modelId);
23
+ if (!normalized) return false;
24
+ if (!normalized.includes(SONNET4_DEPRECATED_MARKER)) return false;
25
+ if (hasAnyMarker(normalized, SONNET45_ALLOWED_MARKERS)) return false;
26
+
27
+ const idx = normalized.indexOf(SONNET4_DEPRECATED_MARKER);
28
+ const next = normalized.at(idx + SONNET4_DEPRECATED_MARKER.length);
29
+ if (!next) return true;
30
+ return next === "-" || next === "_" || next === "." || next === ":";
31
+ }
32
+
33
+ export type TeammateModelSource = "override" | "inherit_leader" | "default";
34
+
35
+ export interface ResolvedTeammateModel {
36
+ source: TeammateModelSource;
37
+ provider?: string;
38
+ modelId?: string;
39
+ warnings: string[];
40
+ }
41
+
42
+ export type ResolveTeammateModelResult =
43
+ | {
44
+ ok: true;
45
+ value: ResolvedTeammateModel;
46
+ }
47
+ | {
48
+ ok: false;
49
+ error: string;
50
+ reason: "invalid_override" | "deprecated_override";
51
+ };
52
+
53
+ export function formatProviderModel(provider: string | undefined, modelId: string | undefined): string | null {
54
+ if (!modelId) return null;
55
+ return provider ? `${provider}/${modelId}` : modelId;
56
+ }
57
+
58
+ export function resolveTeammateModelSelection(input: {
59
+ modelOverride?: string;
60
+ leaderProvider?: string;
61
+ leaderModelId?: string;
62
+ }): ResolveTeammateModelResult {
63
+ const override = trimOrUndefined(input.modelOverride);
64
+ if (override) {
65
+ const slashIdx = override.indexOf("/");
66
+ if (slashIdx >= 0) {
67
+ const provider = override.slice(0, slashIdx).trim();
68
+ const id = override.slice(slashIdx + 1).trim();
69
+ if (!provider || !id) {
70
+ return {
71
+ ok: false,
72
+ reason: "invalid_override",
73
+ error: `Invalid model override '${override}'. Expected <provider>/<modelId>.`,
74
+ };
75
+ }
76
+ if (isDeprecatedTeammateModelId(id)) {
77
+ return {
78
+ ok: false,
79
+ reason: "deprecated_override",
80
+ error: `Model override '${override}' is deprecated. Choose a current model id.`,
81
+ };
82
+ }
83
+ return {
84
+ ok: true,
85
+ value: {
86
+ source: "override",
87
+ provider,
88
+ modelId: id,
89
+ warnings: [],
90
+ },
91
+ };
92
+ }
93
+
94
+ if (isDeprecatedTeammateModelId(override)) {
95
+ return {
96
+ ok: false,
97
+ reason: "deprecated_override",
98
+ error: `Model override '${override}' is deprecated. Choose a current model id.`,
99
+ };
100
+ }
101
+
102
+ const leaderProvider = trimOrUndefined(input.leaderProvider);
103
+ const warnings: string[] = [];
104
+ if (!leaderProvider) {
105
+ warnings.push(
106
+ `Model override '${override}' provided without a provider. ` +
107
+ `Teammate will use its default provider; use <provider>/<modelId> to force one.`,
108
+ );
109
+ }
110
+ return {
111
+ ok: true,
112
+ value: {
113
+ source: "override",
114
+ provider: leaderProvider,
115
+ modelId: override,
116
+ warnings,
117
+ },
118
+ };
119
+ }
120
+
121
+ const leaderModelId = trimOrUndefined(input.leaderModelId);
122
+ const leaderProvider = trimOrUndefined(input.leaderProvider);
123
+ if (leaderModelId && !isDeprecatedTeammateModelId(leaderModelId)) {
124
+ return {
125
+ ok: true,
126
+ value: {
127
+ source: "inherit_leader",
128
+ provider: leaderProvider,
129
+ modelId: leaderModelId,
130
+ warnings: [],
131
+ },
132
+ };
133
+ }
134
+
135
+ return {
136
+ ok: true,
137
+ value: {
138
+ source: "default",
139
+ warnings: [],
140
+ },
141
+ };
142
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Shared name sanitization.
3
+ *
4
+ * Must be stable across leader/worker/mailbox so file paths and mailbox ids match.
5
+ */
6
+ export function sanitizeName(name: string): string {
7
+ return name.replace(/[^a-zA-Z0-9_-]/g, "-");
8
+ }
9
+
10
+ /** Pool of person names for auto-generated comrades (soviet style). */
11
+ export const COMRADE_NAME_POOL: readonly string[] = [
12
+ "ivan",
13
+ "natasha",
14
+ "boris",
15
+ "olga",
16
+ "dmitri",
17
+ "katya",
18
+ "sergei",
19
+ "anya",
20
+ "nikolai",
21
+ "mila",
22
+ "viktor",
23
+ "lena",
24
+ "pavel",
25
+ "zoya",
26
+ "alexei",
27
+ "daria",
28
+ "yuri",
29
+ "vera",
30
+ "andrei",
31
+ "sonya",
32
+ "maxim",
33
+ "nina",
34
+ "roman",
35
+ "tanya",
36
+ "leon",
37
+ "irina",
38
+ "oleg",
39
+ "nadia",
40
+ "artem",
41
+ "lydia",
42
+ ];
43
+
44
+ /** Pool of pirate-ish names for auto-generated mateys (pirate style). */
45
+ export const PIRATE_NAME_POOL: readonly string[] = [
46
+ "blackbeard",
47
+ "anne-bonny",
48
+ "calico-jack",
49
+ "mary-read",
50
+ "long-john",
51
+ "redbeard",
52
+ "silver",
53
+ "bones",
54
+ "hook",
55
+ "sparrow",
56
+ "gibbs",
57
+ "barbossa",
58
+ "rackham",
59
+ "flint",
60
+ "morgan",
61
+ "teach",
62
+ ];
63
+
64
+ /**
65
+ * Pick `count` names from the pool that aren't already taken.
66
+ * Falls back to `<name>-2`, `<name>-3` etc. if pool is exhausted.
67
+ */
68
+ export function pickNamesFromPool(opts: {
69
+ pool: readonly string[];
70
+ count: number;
71
+ taken: ReadonlySet<string>;
72
+ fallbackBase: string;
73
+ }): string[] {
74
+ const { pool, count, taken, fallbackBase } = opts;
75
+ const available = pool.filter((n) => !taken.has(n));
76
+ const picked: string[] = [];
77
+
78
+ for (let i = 0; i < count; i++) {
79
+ const avail = available[i];
80
+ if (avail !== undefined) {
81
+ picked.push(avail);
82
+ continue;
83
+ }
84
+
85
+ // Exhaust pool: append suffix to cycle through names again
86
+ const base = pool.length > 0 ? (pool[i % pool.length] ?? fallbackBase) : fallbackBase;
87
+ let suffix = 2;
88
+ let candidate = `${base}-${suffix}`;
89
+ while (taken.has(candidate) || picked.includes(candidate)) {
90
+ suffix++;
91
+ candidate = `${base}-${suffix}`;
92
+ }
93
+ picked.push(candidate);
94
+ }
95
+
96
+ return picked;
97
+ }
98
+
99
+ export function pickComradeNames(count: number, taken: ReadonlySet<string>): string[] {
100
+ return pickNamesFromPool({ pool: COMRADE_NAME_POOL, count, taken, fallbackBase: "comrade" });
101
+ }
102
+
103
+ export function pickPirateNames(count: number, taken: ReadonlySet<string>): string[] {
104
+ return pickNamesFromPool({ pool: PIRATE_NAME_POOL, count, taken, fallbackBase: "matey" });
105
+ }
106
+
107
+ /**
108
+ * Deterministic default names for normal style.
109
+ * Produces agent1, agent2, ... skipping taken.
110
+ */
111
+ export function pickAgentNames(count: number, taken: ReadonlySet<string>): string[] {
112
+ const picked: string[] = [];
113
+ let i = 1;
114
+ while (picked.length < count) {
115
+ const candidate = `agent${i}`;
116
+ i++;
117
+ if (taken.has(candidate) || picked.includes(candidate)) continue;
118
+ picked.push(candidate);
119
+ }
120
+ return picked;
121
+ }
@@ -0,0 +1,37 @@
1
+ import * as path from "node:path";
2
+ import { getAgentDir } from "indusagi-coding-agent";
3
+
4
+ /**
5
+ * Root directory for all team artifacts (config, sessions, mailboxes, tasks).
6
+ *
7
+ * Default: `${getAgentDir()}/teams`
8
+ * Override: set `PI_TEAMS_ROOT_DIR` to an absolute path (recommended) or a path
9
+ * relative to the agent dir.
10
+ */
11
+ export function getTeamsRootDir(): string {
12
+ const override = process.env.PI_TEAMS_ROOT_DIR;
13
+ if (override && override.trim()) {
14
+ const p = override.trim();
15
+ return path.isAbsolute(p) ? p : path.join(getAgentDir(), p);
16
+ }
17
+ return path.join(getAgentDir(), "teams");
18
+ }
19
+
20
+ export function getTeamDir(teamId: string): string {
21
+ return path.join(getTeamsRootDir(), teamId);
22
+ }
23
+
24
+ /** Directory for custom team UI styles (terminology + name rules). */
25
+ export function getTeamsStylesDir(): string {
26
+ return path.join(getTeamsRootDir(), "_styles");
27
+ }
28
+
29
+ /** Directory for hook scripts and hook configuration (quality gates). */
30
+ export function getTeamsHooksDir(): string {
31
+ const override = process.env.PI_TEAMS_HOOKS_DIR;
32
+ if (override && override.trim()) {
33
+ const p = override.trim();
34
+ return path.isAbsolute(p) ? p : path.join(getTeamsRootDir(), p);
35
+ }
36
+ return path.join(getTeamsRootDir(), "_hooks");
37
+ }
@@ -0,0 +1,241 @@
1
+ import type { TeamTask } from "./task-store.js";
2
+
3
+ export const TEAM_MAILBOX_NS = "team";
4
+
5
+ export function taskAssignmentPayload(task: TeamTask, assignedBy: string): {
6
+ type: "task_assignment";
7
+ taskId: string;
8
+ subject: string;
9
+ description: string;
10
+ assignedBy: string;
11
+ timestamp: string;
12
+ } {
13
+ return {
14
+ type: "task_assignment",
15
+ taskId: task.id,
16
+ subject: task.subject,
17
+ description: task.description,
18
+ assignedBy,
19
+ timestamp: new Date().toISOString(),
20
+ };
21
+ }
22
+
23
+ function safeParseJson(text: string): unknown | null {
24
+ try {
25
+ const parsed: unknown = JSON.parse(text);
26
+ return parsed;
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ function isRecord(v: unknown): v is Record<string, unknown> {
33
+ return typeof v === "object" && v !== null;
34
+ }
35
+
36
+ function getString(obj: Record<string, unknown>, key: string): string | undefined {
37
+ const v = obj[key];
38
+ return typeof v === "string" ? v : undefined;
39
+ }
40
+
41
+ // Leader-side inbox messages
42
+
43
+ export function isIdleNotification(
44
+ text: string,
45
+ ): {
46
+ from: string;
47
+ timestamp?: string;
48
+ completedTaskId?: string;
49
+ completedStatus?: string;
50
+ failureReason?: string;
51
+ } | null {
52
+ const obj = safeParseJson(text);
53
+ if (!isRecord(obj)) return null;
54
+ if (getString(obj, "type") !== "idle_notification") return null;
55
+ return {
56
+ from: getString(obj, "from") ?? "unknown",
57
+ timestamp: getString(obj, "timestamp"),
58
+ completedTaskId: getString(obj, "completedTaskId"),
59
+ completedStatus: getString(obj, "completedStatus"),
60
+ failureReason: getString(obj, "failureReason"),
61
+ };
62
+ }
63
+
64
+ export function isShutdownApproved(
65
+ text: string,
66
+ ): {
67
+ from: string;
68
+ requestId: string;
69
+ timestamp?: string;
70
+ } | null {
71
+ const obj = safeParseJson(text);
72
+ if (!isRecord(obj)) return null;
73
+ if (getString(obj, "type") !== "shutdown_approved") return null;
74
+ const requestId = getString(obj, "requestId");
75
+ if (!requestId) return null;
76
+ return {
77
+ from: getString(obj, "from") ?? "unknown",
78
+ requestId,
79
+ timestamp: getString(obj, "timestamp"),
80
+ };
81
+ }
82
+
83
+ export function isShutdownRejected(
84
+ text: string,
85
+ ): {
86
+ from: string;
87
+ requestId: string;
88
+ reason: string;
89
+ timestamp?: string;
90
+ } | null {
91
+ const obj = safeParseJson(text);
92
+ if (!isRecord(obj)) return null;
93
+ if (getString(obj, "type") !== "shutdown_rejected") return null;
94
+ const requestId = getString(obj, "requestId");
95
+ if (!requestId) return null;
96
+ return {
97
+ from: getString(obj, "from") ?? "unknown",
98
+ requestId,
99
+ reason: getString(obj, "reason") ?? "",
100
+ timestamp: getString(obj, "timestamp"),
101
+ };
102
+ }
103
+
104
+ export function isPlanApprovalRequest(
105
+ text: string,
106
+ ): {
107
+ requestId: string;
108
+ from: string;
109
+ plan: string;
110
+ taskId?: string;
111
+ timestamp?: string;
112
+ } | null {
113
+ const obj = safeParseJson(text);
114
+ if (!isRecord(obj)) return null;
115
+ if (getString(obj, "type") !== "plan_approval_request") return null;
116
+ const requestId = getString(obj, "requestId");
117
+ const from = getString(obj, "from");
118
+ const plan = getString(obj, "plan");
119
+ if (!requestId || !from || !plan) return null;
120
+ return {
121
+ requestId,
122
+ from,
123
+ plan,
124
+ taskId: getString(obj, "taskId"),
125
+ timestamp: getString(obj, "timestamp"),
126
+ };
127
+ }
128
+
129
+ export function isPeerDmSent(
130
+ text: string,
131
+ ): {
132
+ from: string;
133
+ to: string;
134
+ summary: string;
135
+ timestamp?: string;
136
+ } | null {
137
+ const obj = safeParseJson(text);
138
+ if (!isRecord(obj)) return null;
139
+ if (getString(obj, "type") !== "peer_dm_sent") return null;
140
+ const from = getString(obj, "from");
141
+ const to = getString(obj, "to");
142
+ const summary = getString(obj, "summary");
143
+ if (!from || !to || !summary) return null;
144
+ return {
145
+ from,
146
+ to,
147
+ summary,
148
+ timestamp: getString(obj, "timestamp"),
149
+ };
150
+ }
151
+
152
+ // Worker-side inbox messages
153
+
154
+ export function isTaskAssignmentMessage(
155
+ text: string,
156
+ ): { taskId: string; subject?: string; description?: string; assignedBy?: string } | null {
157
+ const obj = safeParseJson(text);
158
+ if (!isRecord(obj)) return null;
159
+ if (getString(obj, "type") !== "task_assignment") return null;
160
+ const taskId = getString(obj, "taskId");
161
+ if (!taskId) return null;
162
+ return {
163
+ taskId,
164
+ subject: getString(obj, "subject"),
165
+ description: getString(obj, "description"),
166
+ assignedBy: getString(obj, "assignedBy"),
167
+ };
168
+ }
169
+
170
+ export function isShutdownRequestMessage(
171
+ text: string,
172
+ ): { requestId: string; from?: string; reason?: string; timestamp?: string } | null {
173
+ const obj = safeParseJson(text);
174
+ if (!isRecord(obj)) return null;
175
+ if (getString(obj, "type") !== "shutdown_request") return null;
176
+ const requestId = getString(obj, "requestId");
177
+ if (!requestId) return null;
178
+ return {
179
+ requestId,
180
+ from: getString(obj, "from"),
181
+ reason: getString(obj, "reason"),
182
+ timestamp: getString(obj, "timestamp"),
183
+ };
184
+ }
185
+
186
+ export function isSetSessionNameMessage(text: string): { name: string } | null {
187
+ const obj = safeParseJson(text);
188
+ if (!isRecord(obj)) return null;
189
+ if (getString(obj, "type") !== "set_session_name") return null;
190
+ const name = getString(obj, "name");
191
+ if (!name) return null;
192
+ return { name };
193
+ }
194
+
195
+ export function isAbortRequestMessage(
196
+ text: string,
197
+ ): { requestId: string; from?: string; taskId?: string; reason?: string; timestamp?: string } | null {
198
+ const obj = safeParseJson(text);
199
+ if (!isRecord(obj)) return null;
200
+ if (getString(obj, "type") !== "abort_request") return null;
201
+ const requestId = getString(obj, "requestId");
202
+ if (!requestId) return null;
203
+ return {
204
+ requestId,
205
+ from: getString(obj, "from"),
206
+ taskId: getString(obj, "taskId"),
207
+ reason: getString(obj, "reason"),
208
+ timestamp: getString(obj, "timestamp"),
209
+ };
210
+ }
211
+
212
+ export function isPlanApprovedMessage(text: string): { requestId: string; from: string; timestamp: string } | null {
213
+ const obj = safeParseJson(text);
214
+ if (!isRecord(obj)) return null;
215
+ if (getString(obj, "type") !== "plan_approved") return null;
216
+ const requestId = getString(obj, "requestId");
217
+ const from = getString(obj, "from");
218
+ if (!requestId || !from) return null;
219
+ return {
220
+ requestId,
221
+ from,
222
+ timestamp: getString(obj, "timestamp") ?? "",
223
+ };
224
+ }
225
+
226
+ export function isPlanRejectedMessage(
227
+ text: string,
228
+ ): { requestId: string; from: string; feedback: string; timestamp: string } | null {
229
+ const obj = safeParseJson(text);
230
+ if (!isRecord(obj)) return null;
231
+ if (getString(obj, "type") !== "plan_rejected") return null;
232
+ const requestId = getString(obj, "requestId");
233
+ const from = getString(obj, "from");
234
+ if (!requestId || !from) return null;
235
+ return {
236
+ requestId,
237
+ from,
238
+ feedback: getString(obj, "feedback") ?? "",
239
+ timestamp: getString(obj, "timestamp") ?? "",
240
+ };
241
+ }
@@ -0,0 +1,36 @@
1
+ import type { ThinkingLevel } from "indusagi/agent";
2
+ import type { ExtensionContext } from "indusagi-coding-agent";
3
+
4
+ export type ContextMode = "fresh" | "branch";
5
+ export type WorkspaceMode = "shared" | "worktree";
6
+
7
+ export interface SpawnTeammateOptions {
8
+ name: string;
9
+ mode?: ContextMode;
10
+ workspaceMode?: WorkspaceMode;
11
+ planRequired?: boolean;
12
+ /**
13
+ * Optional model override for the spawned teammate.
14
+ *
15
+ * Supported forms:
16
+ * - "<provider>/<modelId>" (e.g. "openai-codex/gpt-5.1-codex-mini")
17
+ * - "<modelId>" (provider inherited from leader when available)
18
+ */
19
+ model?: string;
20
+ /** Optional thinking level override for the spawned teammate. */
21
+ thinking?: ThinkingLevel;
22
+ }
23
+
24
+ export type SpawnTeammateResult =
25
+ | {
26
+ ok: true;
27
+ name: string;
28
+ mode: ContextMode;
29
+ workspaceMode: WorkspaceMode;
30
+ childCwd?: string;
31
+ note?: string;
32
+ warnings: string[];
33
+ }
34
+ | { ok: false; error: string };
35
+
36
+ export type SpawnTeammateFn = (ctx: ExtensionContext, opts: SpawnTeammateOptions) => Promise<SpawnTeammateResult>;