agents-pod 0.3.6 → 0.3.7
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/CHANGELOG.md +40 -0
- package/README.md +55 -5
- package/examples/feishu/README.md +65 -0
- package/package.json +1 -1
- package/src/cli.ts +521 -58
- package/src/doctor.ts +13 -0
- package/src/extension/management.ts +15 -4
- package/src/listeners.ts +14 -2
- package/src/sources.ts +40 -19
package/src/cli.ts
CHANGED
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
newListenerId,
|
|
41
41
|
readListeners,
|
|
42
42
|
writeListeners,
|
|
43
|
+
type ClaimRecord,
|
|
43
44
|
type ListenerRecord,
|
|
44
45
|
} from "./listeners.ts";
|
|
45
46
|
import { acquireLock, readLock, releaseLock, sleep } from "./locks.ts";
|
|
@@ -116,27 +117,59 @@ function launchWrapperDetached(podId: string, agentId?: string, warm = false): v
|
|
|
116
117
|
* value; one that arrives without a value is reported as `dangling` — left in
|
|
117
118
|
* `rest` it would become part of a goal (`spawn fix it --wake-budget`) and the
|
|
118
119
|
* command would proceed with the flag silently unset.
|
|
120
|
+
*
|
|
121
|
+
* A token that begins with `--` is the *next* flag's name, never this flag's
|
|
122
|
+
* value: `spawn … --model --dormant` used to set `model` to the literal string
|
|
123
|
+
* `--dormant`, drop the dormancy and launch the wake-up the flag was there to
|
|
124
|
+
* prevent (#43 review). No flag's value is a flag name — an agent id, a model
|
|
125
|
+
* id, a path, a pod id and a count never start with `--` — so the token stays
|
|
126
|
+
* unconsumed and this flag is reported dangling, exactly as if it had arrived
|
|
127
|
+
* last. A single leading `-` is still a value (`--wake-budget -3` must reach the
|
|
128
|
+
* integer check, not the usage error).
|
|
119
129
|
*/
|
|
120
130
|
function parseFlags(
|
|
121
131
|
words: string[],
|
|
122
132
|
allowed: readonly string[],
|
|
123
|
-
|
|
133
|
+
multi: readonly string[] = [],
|
|
134
|
+
): {
|
|
135
|
+
flags: Record<string, string>;
|
|
136
|
+
multiFlags: Record<string, string[]>;
|
|
137
|
+
rest: string[];
|
|
138
|
+
dangling: string[];
|
|
139
|
+
} {
|
|
124
140
|
const flags: Record<string, string> = {};
|
|
141
|
+
const multiFlags: Record<string, string[]> = {};
|
|
125
142
|
const rest: string[] = [];
|
|
126
143
|
const dangling: string[] = [];
|
|
127
144
|
for (let index = 0; index < words.length; index += 1) {
|
|
128
145
|
const word = words[index]!;
|
|
129
146
|
const name = word.startsWith("--") ? word.slice(2) : undefined;
|
|
130
|
-
const
|
|
147
|
+
const next = words[index + 1];
|
|
148
|
+
const value = next !== undefined && !next.startsWith("--") ? next : undefined;
|
|
131
149
|
if (name !== undefined && allowed.includes(name) && value !== undefined) {
|
|
132
|
-
|
|
150
|
+
// A flag that may repeat keeps every value (ADR 0034's `--chat`): a scalar
|
|
151
|
+
// would silently drop all but the last group.
|
|
152
|
+
if (multi.includes(name)) (multiFlags[name] ??= []).push(value);
|
|
153
|
+
else flags[name] = value;
|
|
133
154
|
index += 1;
|
|
134
155
|
} else {
|
|
135
156
|
if (name !== undefined && allowed.includes(name)) dangling.push(`--${name}`);
|
|
136
157
|
rest.push(word);
|
|
137
158
|
}
|
|
138
159
|
}
|
|
139
|
-
return { flags, rest, dangling };
|
|
160
|
+
return { flags, multiFlags, rest, dangling };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The routing filter as one rule (ADR 0034): the groups an item's `chat_id` must
|
|
165
|
+
* be among, ANDed with the text substring. An item with no routing field can
|
|
166
|
+
* never satisfy a filter; that is a fact about directory items, and `listener
|
|
167
|
+
* add` refuses to write a filter they could never match.
|
|
168
|
+
*/
|
|
169
|
+
function routingChats(values: readonly string[] | undefined): string[] | undefined {
|
|
170
|
+
if (values === undefined) return undefined;
|
|
171
|
+
const chats = values.map((value) => value.trim()).filter((value) => value !== "");
|
|
172
|
+
return chats.length === 0 ? undefined : chats;
|
|
140
173
|
}
|
|
141
174
|
|
|
142
175
|
/** Exponential backoff after `failed`/`lost`: 1m ×2, capped at 15m (ADR 0011). */
|
|
@@ -720,10 +753,47 @@ function livePod(claimedBy: string, pods: PodRecord[]): boolean {
|
|
|
720
753
|
return pod !== undefined && pod.state !== "done" && pod.state !== "cancelled";
|
|
721
754
|
}
|
|
722
755
|
|
|
723
|
-
/**
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
756
|
+
/**
|
|
757
|
+
* A Listener's match rule (ADRs 0007, 0034): a routing filter over the item's
|
|
758
|
+
* `chat_id`, ANDed with a plain substring over title and body. Either half alone
|
|
759
|
+
* is a rule; both together must hold.
|
|
760
|
+
*/
|
|
761
|
+
function matchesListener(
|
|
762
|
+
rule: Pick<ListenerRecord, "match" | "chats">,
|
|
763
|
+
item: InboxItem,
|
|
764
|
+
): boolean {
|
|
765
|
+
if (rule.chats !== undefined && rule.chats.length > 0) {
|
|
766
|
+
if (item.chatId === undefined || !rule.chats.includes(item.chatId)) return false;
|
|
767
|
+
}
|
|
768
|
+
if (rule.match === undefined || rule.match === "") return true;
|
|
769
|
+
return `${item.title}\n${item.body}`.includes(rule.match);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* A Source claim owns its item after the drain has moved on (ADR 0034). Claims
|
|
774
|
+
* written before that decision fall back to this pass's scan. Filesystem inbox
|
|
775
|
+
* claims carry no copy at all: `{{path}}` and deletion are meaningful there, and
|
|
776
|
+
* the file itself is the truth.
|
|
777
|
+
*/
|
|
778
|
+
function itemForClaim(
|
|
779
|
+
listener: ListenerRecord,
|
|
780
|
+
claim: ClaimRecord,
|
|
781
|
+
scanned: InboxItem | undefined,
|
|
782
|
+
): InboxItem | undefined {
|
|
783
|
+
if (listener.source === undefined || claim.title === undefined || claim.body === undefined) {
|
|
784
|
+
return scanned;
|
|
785
|
+
}
|
|
786
|
+
return {
|
|
787
|
+
id: claim.item,
|
|
788
|
+
title: claim.title,
|
|
789
|
+
body: claim.body,
|
|
790
|
+
path: "",
|
|
791
|
+
mtimeMs: 0,
|
|
792
|
+
size: 0,
|
|
793
|
+
...(claim.chatId === undefined ? {} : { chatId: claim.chatId }),
|
|
794
|
+
...(claim.rootId === undefined ? {} : { rootId: claim.rootId }),
|
|
795
|
+
...(claim.threadId === undefined ? {} : { threadId: claim.threadId }),
|
|
796
|
+
};
|
|
727
797
|
}
|
|
728
798
|
|
|
729
799
|
/**
|
|
@@ -741,10 +811,20 @@ async function listenersPass(project: string, drained: SourceDrain[]): Promise<v
|
|
|
741
811
|
|
|
742
812
|
/** The inbox, not the listener, is the claim namespace (ADR 0013). */
|
|
743
813
|
const claimedInInbox = new Set<string>();
|
|
814
|
+
const deferredClaims = new Set<ClaimRecord>();
|
|
744
815
|
for (const listener of listeners) {
|
|
745
816
|
listener.claimed ??= [];
|
|
746
817
|
listener.seen ??= [];
|
|
747
|
-
for (const claim of listener.claimed)
|
|
818
|
+
for (const claim of listener.claimed) {
|
|
819
|
+
claimedInInbox.add(claimKey(claim.inbox, claim.item));
|
|
820
|
+
if (
|
|
821
|
+
claim.spawnedPodId === undefined &&
|
|
822
|
+
claim.blocked === undefined &&
|
|
823
|
+
claim.appended !== true
|
|
824
|
+
) {
|
|
825
|
+
deferredClaims.add(claim);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
748
828
|
}
|
|
749
829
|
|
|
750
830
|
const itemsByListener = new Map<string, Map<string, InboxItem>>();
|
|
@@ -813,9 +893,9 @@ async function listenersPass(project: string, drained: SourceDrain[]): Promise<v
|
|
|
813
893
|
).length;
|
|
814
894
|
// A bound pod is served by a queue (ADR 0014), so only template spawns
|
|
815
895
|
// compete for its slots — and a pending claim already holds one. A Source
|
|
816
|
-
// claim is unbounded too:
|
|
817
|
-
//
|
|
818
|
-
//
|
|
896
|
+
// claim is unbounded too: the cursor commits behind the claims, so an
|
|
897
|
+
// unclaimed item would be lost rather than re-scanned. Its item copy lets
|
|
898
|
+
// the bound defer only materialization, not the content itself (ADR 0034).
|
|
819
899
|
let room =
|
|
820
900
|
listener.mode === "pod" || listener.source !== undefined
|
|
821
901
|
? Number.POSITIVE_INFINITY
|
|
@@ -824,16 +904,28 @@ async function listenersPass(project: string, drained: SourceDrain[]): Promise<v
|
|
|
824
904
|
for (const item of stable) {
|
|
825
905
|
if (room <= 0) break;
|
|
826
906
|
const key = claimKey(origin, item.id);
|
|
827
|
-
if (claimedInInbox.has(key) || !matchesListener(listener
|
|
907
|
+
if (claimedInInbox.has(key) || !matchesListener(listener, item)) continue;
|
|
828
908
|
// One claim shape for both targets (ADR 0013): a template materializes a
|
|
829
909
|
// pod from it later, a bound standing pod takes it as a document in
|
|
830
|
-
// phase 3 (ADR 0014). Claim first, always.
|
|
910
|
+
// phase 3 (ADR 0014). Claim first, always. Only a Source claim copies its
|
|
911
|
+
// item (ADR 0034): a filesystem item is still on disk and the document is
|
|
912
|
+
// the truth there, so a copy would only swell `listeners.json` with
|
|
913
|
+
// bodies nothing ever reads.
|
|
831
914
|
listener.claimed.push({
|
|
832
915
|
inbox: origin,
|
|
833
916
|
item: item.id,
|
|
834
917
|
...(sourceDrain?.drainedAt === undefined
|
|
835
918
|
? {}
|
|
836
919
|
: { claimedAt: sourceDrain.drainedAt }),
|
|
920
|
+
...(listener.source === undefined
|
|
921
|
+
? {}
|
|
922
|
+
: {
|
|
923
|
+
title: item.title,
|
|
924
|
+
body: item.body,
|
|
925
|
+
...(item.chatId === undefined ? {} : { chatId: item.chatId }),
|
|
926
|
+
...(item.rootId === undefined ? {} : { rootId: item.rootId }),
|
|
927
|
+
...(item.threadId === undefined ? {} : { threadId: item.threadId }),
|
|
928
|
+
}),
|
|
837
929
|
});
|
|
838
930
|
claimedInInbox.add(key);
|
|
839
931
|
changed = true;
|
|
@@ -841,6 +933,18 @@ async function listenersPass(project: string, drained: SourceDrain[]): Promise<v
|
|
|
841
933
|
}
|
|
842
934
|
}
|
|
843
935
|
|
|
936
|
+
// Routing makes "nobody wants this group" an ordinary state, so the pass counts
|
|
937
|
+
// it (ADR 0034): unique ids only, because the overlap re-emits the same message
|
|
938
|
+
// pass after pass, and a filter's whole job is to leave some of them unclaimed.
|
|
939
|
+
for (const drain of drained) {
|
|
940
|
+
if (drain.error !== undefined) continue;
|
|
941
|
+
let unrouted = 0;
|
|
942
|
+
for (const id of new Set(drain.items.map((item) => item.id))) {
|
|
943
|
+
if (!claimedInInbox.has(claimKey(`source:${drain.name}`, id))) unrouted += 1;
|
|
944
|
+
}
|
|
945
|
+
drain.unrouted = unrouted;
|
|
946
|
+
}
|
|
947
|
+
|
|
844
948
|
// The claim is on disk before any pod is touched (ADR 0007).
|
|
845
949
|
if (changed) await writeListeners(listeners);
|
|
846
950
|
|
|
@@ -872,7 +976,11 @@ async function listenersPass(project: string, drained: SourceDrain[]): Promise<v
|
|
|
872
976
|
).length;
|
|
873
977
|
for (const claim of pending()) {
|
|
874
978
|
if (live >= listener.maxConcurrent) break; // bound: the claim waits a pass
|
|
875
|
-
const item =
|
|
979
|
+
const item = itemForClaim(
|
|
980
|
+
listener,
|
|
981
|
+
claim,
|
|
982
|
+
itemsByListener.get(listener.id)?.get(claim.item),
|
|
983
|
+
);
|
|
876
984
|
if (item === undefined) {
|
|
877
985
|
claim.blocked = "item vanished before materialization";
|
|
878
986
|
await appendCronLog(
|
|
@@ -912,7 +1020,7 @@ async function listenersPass(project: string, drained: SourceDrain[]): Promise<v
|
|
|
912
1020
|
let appended = false;
|
|
913
1021
|
for (const listener of listeners) {
|
|
914
1022
|
if (listener.mode !== "pod" || listener.podId === undefined) continue;
|
|
915
|
-
if (unscannable.has(listener.id)) continue; //
|
|
1023
|
+
if (unscannable.has(listener.id)) continue; // a failed scan still waits for a successful retry
|
|
916
1024
|
const waiting = listener.claimed.filter(
|
|
917
1025
|
(claim) => claim.blocked === undefined && claim.appended !== true,
|
|
918
1026
|
);
|
|
@@ -928,12 +1036,12 @@ async function listenersPass(project: string, drained: SourceDrain[]): Promise<v
|
|
|
928
1036
|
}
|
|
929
1037
|
if (pod.state !== "idle" || pod.policy?.parked) continue;
|
|
930
1038
|
|
|
931
|
-
// The body is copied at append time (ADR 0014)
|
|
932
|
-
//
|
|
1039
|
+
// The body is copied at append time (ADR 0014). A Source claim supplies its
|
|
1040
|
+
// own copy; a legacy claim or filesystem inbox still needs the current scan.
|
|
933
1041
|
const scanned = itemsByListener.get(listener.id);
|
|
934
1042
|
const batch: InboxItem[] = [];
|
|
935
1043
|
for (const claim of waiting) {
|
|
936
|
-
const item = scanned?.get(claim.item);
|
|
1044
|
+
const item = itemForClaim(listener, claim, scanned?.get(claim.item));
|
|
937
1045
|
if (item === undefined) {
|
|
938
1046
|
claim.blocked = "item vanished before append";
|
|
939
1047
|
await appendCronLog(
|
|
@@ -979,10 +1087,11 @@ async function listenersPass(project: string, drained: SourceDrain[]): Promise<v
|
|
|
979
1087
|
|
|
980
1088
|
// Two stores, in this order: claims first, then each successful cursor in
|
|
981
1089
|
// the source store's own atomic write. A bound standing pod adds one gate:
|
|
982
|
-
// its claims must also be appended,
|
|
983
|
-
//
|
|
984
|
-
//
|
|
985
|
-
//
|
|
1090
|
+
// its claims must also be appended, preserving ADR 0014's document handoff.
|
|
1091
|
+
// While a wake-up holds the append back, the unchanged cursor makes the next
|
|
1092
|
+
// pass drain the same items again; their snapshots now cover a drain that
|
|
1093
|
+
// does not (ADR 0034). A crash between writes replays into id and document
|
|
1094
|
+
// dedupe rather than repeating work (ADR 0024).
|
|
986
1095
|
// A Listener removed while its drain was running no longer licenses a cursor.
|
|
987
1096
|
const consumed = new Set(listeners.flatMap((listener) => listener.source ?? []));
|
|
988
1097
|
const waitingForAppend = new Set(
|
|
@@ -1002,8 +1111,18 @@ async function listenersPass(project: string, drained: SourceDrain[]): Promise<v
|
|
|
1002
1111
|
const committable = drained.filter(
|
|
1003
1112
|
(drain) => consumed.has(drain.name) && !waitingForAppend.has(drain.name),
|
|
1004
1113
|
);
|
|
1005
|
-
const pruning = pruneSourceClaims(listeners, committable);
|
|
1114
|
+
const pruning = pruneSourceClaims(listeners, committable, deferredClaims);
|
|
1006
1115
|
if (pruning.changed) await writeListeners(pruning.listeners);
|
|
1116
|
+
// The count is a fact of the pass that committed it, printed once: a group an
|
|
1117
|
+
// operator never aimed a Listener at is legitimate, a group nothing wants is
|
|
1118
|
+
// what they need told (ADR 0034).
|
|
1119
|
+
for (const drain of committable) {
|
|
1120
|
+
if ((drain.unrouted ?? 0) > 0) {
|
|
1121
|
+
await appendCronLog(
|
|
1122
|
+
`source ${drain.name}: ${drain.unrouted} item(s) matched no listener (unrouted, the cursor passed them)`,
|
|
1123
|
+
);
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1007
1126
|
await commitSourceCursors(committable);
|
|
1008
1127
|
}
|
|
1009
1128
|
|
|
@@ -1091,18 +1210,30 @@ async function statusCmd(
|
|
|
1091
1210
|
const last = pod.wakeUps.at(-1);
|
|
1092
1211
|
out(pod.id);
|
|
1093
1212
|
out(` goal: ${goalHeadline(pod.goal)}`);
|
|
1094
|
-
// The suffix qualifies a resting pod: `awaiting` says why the tick leaves it
|
|
1095
|
-
// alone, and a live wake-up is a different answer to the same question.
|
|
1096
|
-
const awaiting = pod.policy?.awaiting === true && pod.state === "idle";
|
|
1097
|
-
out(` state: ${pod.state}${awaiting ? " (awaiting)" : ""}`);
|
|
1098
1213
|
// Who admits this Pod (ADR 0030) — the fact that decides whether the tick
|
|
1099
|
-
// wakes it at all, so
|
|
1214
|
+
// wakes it at all, so the record's owner is read before the state line, which
|
|
1215
|
+
// now also depends on it.
|
|
1100
1216
|
const bound = new Set(
|
|
1101
1217
|
(await readListeners()).flatMap((listener) =>
|
|
1102
1218
|
listener.mode === "pod" && listener.podId !== undefined ? [listener.podId] : [],
|
|
1103
1219
|
),
|
|
1104
1220
|
);
|
|
1105
1221
|
const owner = admissionOf(pod, bound);
|
|
1222
|
+
// The suffix qualifies a resting pod: `awaiting` says why the tick leaves it
|
|
1223
|
+
// alone, and a live wake-up is a different answer to the same question.
|
|
1224
|
+
const awaiting = pod.policy?.awaiting === true && pod.state === "idle";
|
|
1225
|
+
// `dormant` (#43): the record exists and no wake-up has ever run, so the first
|
|
1226
|
+
// one still belongs to the owner — the fact a spawn wrote and a hybrid ask
|
|
1227
|
+
// depends on. Derived, never stored: the empty wake-up list is the fact, and a
|
|
1228
|
+
// marker would have to be cleared by three launchers. A Pod the tick owns is
|
|
1229
|
+
// never reported this way; the tick is on its way to it.
|
|
1230
|
+
const dormant = pod.state === "idle" && pod.wakeUps.length === 0 && owner !== "self";
|
|
1231
|
+
out(
|
|
1232
|
+
` state: ${pod.state}${awaiting ? " (awaiting)" : ""}${
|
|
1233
|
+
dormant ? " (dormant: no wake-up yet)" : ""
|
|
1234
|
+
}`,
|
|
1235
|
+
);
|
|
1236
|
+
// The owner line comes before anything about liveness (ADR 0030).
|
|
1106
1237
|
out(
|
|
1107
1238
|
` admission: ${owner}${
|
|
1108
1239
|
pod.admission === undefined
|
|
@@ -1153,19 +1284,51 @@ function cadenceLine(pod: PodRecord, now: number): string {
|
|
|
1153
1284
|
return `${day} in 24h, ${rhythm}, last trigger ${trigger}`;
|
|
1154
1285
|
}
|
|
1155
1286
|
|
|
1287
|
+
/** Terminal states (ADR 0006): what `list` hides by default and `wake` refuses. */
|
|
1288
|
+
function isTerminal(pod: PodRecord): boolean {
|
|
1289
|
+
return pod.state === "done" || pod.state === "cancelled";
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1156
1292
|
/**
|
|
1157
1293
|
* The attention ruler's first use (DESIGN Part 1): a healthy pod takes one
|
|
1158
1294
|
* quiet line at the bottom; the pods that need a human lead. Read-only.
|
|
1295
|
+
*
|
|
1296
|
+
* Terminal pods are the exception (ADR 0035): a retired Pod asks nothing of a
|
|
1297
|
+
* human, and a wall of `cancelled` records buries the ones that do. They stay
|
|
1298
|
+
* one flag away, and the footer says how many are hidden — silence about them
|
|
1299
|
+
* would make the registry look smaller than it is.
|
|
1159
1300
|
*/
|
|
1160
|
-
async function listCmd(
|
|
1301
|
+
async function listCmd(
|
|
1302
|
+
words: string[],
|
|
1303
|
+
out: (line: string) => void,
|
|
1304
|
+
err: (line: string) => void,
|
|
1305
|
+
): Promise<number> {
|
|
1306
|
+
const unknown = words.filter((word) => word !== "--all" && word !== "--json");
|
|
1307
|
+
if (unknown.length > 0) {
|
|
1308
|
+
err(`Unknown list argument: ${unknown[0]}. Usage: agents-pod list [--all] [--json]`);
|
|
1309
|
+
return 2;
|
|
1310
|
+
}
|
|
1311
|
+
const all = words.includes("--all");
|
|
1161
1312
|
const pods = await readPods();
|
|
1162
|
-
|
|
1163
|
-
|
|
1313
|
+
const visible = all ? pods : pods.filter((pod) => !isTerminal(pod));
|
|
1314
|
+
const hidden = pods.length - visible.length;
|
|
1315
|
+
|
|
1316
|
+
if (visible.length === 0) {
|
|
1317
|
+
out(
|
|
1318
|
+
hidden === 0
|
|
1319
|
+
? "No pods"
|
|
1320
|
+
: `No live pods (${hidden} terminal: agents-pod list --all)`,
|
|
1321
|
+
);
|
|
1322
|
+
return 0;
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
if (words.includes("--json")) {
|
|
1326
|
+
out(JSON.stringify(visible));
|
|
1164
1327
|
return 0;
|
|
1165
1328
|
}
|
|
1166
1329
|
|
|
1167
1330
|
const rows = await Promise.all(
|
|
1168
|
-
|
|
1331
|
+
visible.map(async (pod) => ({ pod, liveness: await livenessOf(pod) })),
|
|
1169
1332
|
);
|
|
1170
1333
|
const needsHuman = (row: (typeof rows)[number]) =>
|
|
1171
1334
|
row.pod.policy?.parked === true || row.liveness !== "healthy";
|
|
@@ -1175,6 +1338,9 @@ async function listCmd(out: (line: string) => void): Promise<number> {
|
|
|
1175
1338
|
const parked = pod.policy?.parked ? " (parked)" : "";
|
|
1176
1339
|
out(`${pod.id} ${pod.state}${parked} ${goalHeadline(pod.goal)}`);
|
|
1177
1340
|
}
|
|
1341
|
+
if (hidden > 0) {
|
|
1342
|
+
out(`${hidden} terminal pod${hidden === 1 ? "" : "s"} hidden (agents-pod list --all)`);
|
|
1343
|
+
}
|
|
1178
1344
|
return 0;
|
|
1179
1345
|
}
|
|
1180
1346
|
|
|
@@ -1268,6 +1434,7 @@ async function wakeCmd(
|
|
|
1268
1434
|
|
|
1269
1435
|
if (pod.state === "done" || pod.state === "cancelled") {
|
|
1270
1436
|
err(`Pod ${pod.id} is ${pod.state}: terminal is terminal — spawn a new pod instead`);
|
|
1437
|
+
err(` or forget it: agents-pod rm ${pod.id}`);
|
|
1271
1438
|
return 2;
|
|
1272
1439
|
}
|
|
1273
1440
|
if (pod.state === "active" || pod.state === "spawning") {
|
|
@@ -1388,7 +1555,12 @@ async function cancelCmd(
|
|
|
1388
1555
|
if (!pod) return 2;
|
|
1389
1556
|
|
|
1390
1557
|
if (pod.state !== "active") {
|
|
1391
|
-
err(
|
|
1558
|
+
err(
|
|
1559
|
+
`pod ${pod.id} is ${pod.state}: nothing to cancel (` +
|
|
1560
|
+
(isTerminal(pod)
|
|
1561
|
+
? `agents-pod rm ${pod.id} removes the record)`
|
|
1562
|
+
: `agents-pod rm ${pod.id} discards it)`),
|
|
1563
|
+
);
|
|
1392
1564
|
return 0;
|
|
1393
1565
|
}
|
|
1394
1566
|
const wrapper = await readWrapperRecord(pod);
|
|
@@ -1414,6 +1586,107 @@ async function cancelCmd(
|
|
|
1414
1586
|
return 0;
|
|
1415
1587
|
}
|
|
1416
1588
|
|
|
1589
|
+
/** Undelivered entries a removal would delete with the workspace (ADR 0025, 0035). */
|
|
1590
|
+
async function pendingOutbox(pod: PodRecord): Promise<number> {
|
|
1591
|
+
const dir = join(podDir(pod.project, pod.id), "outbox");
|
|
1592
|
+
const names = await readdir(dir).catch(() => []);
|
|
1593
|
+
// A `.dead` entry is already a verdict, not a message waiting to go out.
|
|
1594
|
+
return names.filter((name) => !name.startsWith(".") && !name.endsWith(".dead")).length;
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
/**
|
|
1598
|
+
* The record's end (ADR 0035): remove a Pod that no live wake-up owns — its
|
|
1599
|
+
* record and its workspace, so the registry and the disk agree again. This is
|
|
1600
|
+
* garbage collection, not a stop: `cancel` still owns every running process,
|
|
1601
|
+
* and a live pod is refused here rather than deleted under its wrapper.
|
|
1602
|
+
*
|
|
1603
|
+
* Refusals are the references that would be left dangling: a bound Listener
|
|
1604
|
+
* (its claims would wait forever), a dependent Pod (`after` could never be
|
|
1605
|
+
* satisfied), and an undelivered Outbox entry (a message a wake-up already
|
|
1606
|
+
* decided to send). A Listener claim's `spawnedPodId` is deliberately *not* a
|
|
1607
|
+
* reference: it is intake's history, and a missing pod simply reads as not
|
|
1608
|
+
* live (ADR 0034).
|
|
1609
|
+
*/
|
|
1610
|
+
async function removeCmd(
|
|
1611
|
+
rest: string[],
|
|
1612
|
+
out: (line: string) => void,
|
|
1613
|
+
err: (line: string) => void,
|
|
1614
|
+
): Promise<number> {
|
|
1615
|
+
const unknown = rest.filter((word) => word.startsWith("--"));
|
|
1616
|
+
if (unknown.length > 0) {
|
|
1617
|
+
err(`Unknown rm argument: ${unknown[0]}. Usage: agents-pod rm <podId>`);
|
|
1618
|
+
return 2;
|
|
1619
|
+
}
|
|
1620
|
+
const pod = await requirePod(rest[0], "rm", err);
|
|
1621
|
+
if (!pod) return 2;
|
|
1622
|
+
|
|
1623
|
+
if (pod.state === "active" || pod.state === "spawning") {
|
|
1624
|
+
err(`pod ${pod.id} is ${pod.state}: a live wake-up owns it — agents-pod cancel ${pod.id} first`);
|
|
1625
|
+
return 2;
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
const bound = (await readListeners()).filter(
|
|
1629
|
+
(listener) => listener.mode === "pod" && listener.podId === pod.id,
|
|
1630
|
+
);
|
|
1631
|
+
if (bound.length > 0) {
|
|
1632
|
+
err(
|
|
1633
|
+
`pod ${pod.id} is bound by ${bound.map((listener) => listener.id).join(", ")}: remove the listener first`,
|
|
1634
|
+
);
|
|
1635
|
+
return 2;
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
const dependents = (await readPods()).filter((candidate) => candidate.after === pod.id);
|
|
1639
|
+
if (dependents.length > 0) {
|
|
1640
|
+
err(
|
|
1641
|
+
`pod ${pod.id} is the upstream of ${dependents.map((candidate) => candidate.id).join(", ")}: remove the dependent pod${dependents.length === 1 ? "" : "s"} first`,
|
|
1642
|
+
);
|
|
1643
|
+
return 2;
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
const waiting = await pendingOutbox(pod);
|
|
1647
|
+
if (waiting > 0) {
|
|
1648
|
+
const dir = join(podDir(pod.project, pod.id), "outbox");
|
|
1649
|
+
err(
|
|
1650
|
+
`pod ${pod.id} has ${waiting} undelivered outbox entr${waiting === 1 ? "y" : "ies"}: deliver or delete ${dir} first`,
|
|
1651
|
+
);
|
|
1652
|
+
return 2;
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
// The same CAS as every other transition (ADR 0018): the refusal checks above
|
|
1656
|
+
// ran outside the lock, so the state and the references are re-read inside it
|
|
1657
|
+
// and a wake, a spawn or a new dependent that landed meanwhile wins.
|
|
1658
|
+
const removed = await withRegistryLock(async () => {
|
|
1659
|
+
const current = await readPods();
|
|
1660
|
+
const target = current.find((candidate) => candidate.id === pod.id);
|
|
1661
|
+
if (!target || target.state === "active" || target.state === "spawning") return false;
|
|
1662
|
+
if (current.some((candidate) => candidate.after === pod.id)) return false;
|
|
1663
|
+
await writePods(current.filter((candidate) => candidate.id !== pod.id));
|
|
1664
|
+
return true;
|
|
1665
|
+
});
|
|
1666
|
+
if (removed === undefined) {
|
|
1667
|
+
err(`pod ${pod.id}: the registry is locked; nothing removed`);
|
|
1668
|
+
return 1;
|
|
1669
|
+
}
|
|
1670
|
+
if (!removed) {
|
|
1671
|
+
err(`pod ${pod.id} changed while removing it: nothing removed`);
|
|
1672
|
+
return 2;
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
// The record goes first: it is what every read surface shows, and a crash
|
|
1676
|
+
// between the two leaves an invisible directory, not a pod that looks alive.
|
|
1677
|
+
const dir = podDir(pod.project, pod.id);
|
|
1678
|
+
const line = `removed ${pod.id} (${pod.state}): ${goalHeadline(pod.goal)}`;
|
|
1679
|
+
try {
|
|
1680
|
+
await rm(dir, { recursive: true, force: true });
|
|
1681
|
+
} catch (error) {
|
|
1682
|
+
out(line);
|
|
1683
|
+
err(`workspace ${dir} could not be deleted (${(error as Error).message}); remove it by hand`);
|
|
1684
|
+
return 1;
|
|
1685
|
+
}
|
|
1686
|
+
out(line);
|
|
1687
|
+
return 0;
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1417
1690
|
/**
|
|
1418
1691
|
* The one spawn path: record, workspace, then wake admission (ADR 0022). An
|
|
1419
1692
|
* admitted pod gets its detached wrapper; a blocked one waits idle. `spawn`
|
|
@@ -1428,6 +1701,13 @@ export interface SpawnOptions {
|
|
|
1428
1701
|
model?: string;
|
|
1429
1702
|
after?: string;
|
|
1430
1703
|
wakeBudgetPerDay?: number;
|
|
1704
|
+
/**
|
|
1705
|
+
* Write the record and launch nothing (#43, ADR 0033 amendment). A hybrid
|
|
1706
|
+
* Pod's first firing belongs to the handover, not to the spawn that created
|
|
1707
|
+
* it; the blocked path below already writes exactly this record, so dormancy
|
|
1708
|
+
* is the absence of a launch rather than a state of its own.
|
|
1709
|
+
*/
|
|
1710
|
+
dormant?: boolean;
|
|
1431
1711
|
}
|
|
1432
1712
|
|
|
1433
1713
|
async function spawnPodRecord(
|
|
@@ -1436,7 +1716,7 @@ async function spawnPodRecord(
|
|
|
1436
1716
|
options: SpawnOptions,
|
|
1437
1717
|
registryHeld = false,
|
|
1438
1718
|
): Promise<PodRecord> {
|
|
1439
|
-
const { agentId, admission, model, after, wakeBudgetPerDay } = options;
|
|
1719
|
+
const { agentId, admission, model, after, wakeBudgetPerDay, dormant } = options;
|
|
1440
1720
|
const record: PodRecord = {
|
|
1441
1721
|
id: newPodId(),
|
|
1442
1722
|
goal,
|
|
@@ -1475,6 +1755,16 @@ async function spawnPodRecord(
|
|
|
1475
1755
|
return record;
|
|
1476
1756
|
}
|
|
1477
1757
|
|
|
1758
|
+
// Dormant (#43): the record, the workspace and the owner exist; the first
|
|
1759
|
+
// wake-up does not. Nothing in the store says "dormant" — zero wake-ups on
|
|
1760
|
+
// an idle Pod is the fact, and a marker would have to be cleared by three
|
|
1761
|
+
// launchers.
|
|
1762
|
+
if (dormant === true) {
|
|
1763
|
+
record.state = "idle";
|
|
1764
|
+
await writePods(pods);
|
|
1765
|
+
return record;
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1478
1768
|
record.admitted = "spawn";
|
|
1479
1769
|
await writePods(pods);
|
|
1480
1770
|
launchWrapperDetached(record.id, agentId);
|
|
@@ -1504,23 +1794,28 @@ async function spawnCmd(
|
|
|
1504
1794
|
"admission",
|
|
1505
1795
|
"wake-budget",
|
|
1506
1796
|
]);
|
|
1507
|
-
// `--external`
|
|
1508
|
-
// is the shorthand ADR 0030 documents for `--admission
|
|
1509
|
-
//
|
|
1797
|
+
// `--external` and `--dormant` carry no value, so parseFlags leaves them among
|
|
1798
|
+
// the words: the first is the shorthand ADR 0030 documents for `--admission
|
|
1799
|
+
// external`, the second declares the Pod without its spawn wake-up (#43).
|
|
1800
|
+
// Both are taken out before the remaining words compose the goal.
|
|
1510
1801
|
const external = rest.includes("--external");
|
|
1511
|
-
const
|
|
1802
|
+
const dormant = rest.includes("--dormant");
|
|
1803
|
+
const goal = rest
|
|
1804
|
+
.filter((word) => word !== "--external" && word !== "--dormant")
|
|
1805
|
+
.join(" ")
|
|
1806
|
+
.trim();
|
|
1512
1807
|
if (dangling.length > 0) {
|
|
1513
1808
|
// A flag with no value would otherwise compose the goal and leave its own
|
|
1514
1809
|
// setting at the default — a typo that silently disables a guardrail is not
|
|
1515
1810
|
// a guardrail (ADR 0032).
|
|
1516
1811
|
err(`${dangling[0]} needs a value`);
|
|
1517
1812
|
err("Usage: agents-pod spawn <goal prompt> [--agent <id>] [--model <id>] [--cwd <dir>] [--after <podId>]");
|
|
1518
|
-
err(" [--admission intake|external|self | --external] [--wake-budget <n>]");
|
|
1813
|
+
err(" [--admission intake|external|self | --external] [--wake-budget <n>] [--dormant]");
|
|
1519
1814
|
return 2;
|
|
1520
1815
|
}
|
|
1521
1816
|
if (!goal) {
|
|
1522
1817
|
err("Usage: agents-pod spawn <goal prompt> [--agent <id>] [--model <id>] [--cwd <dir>] [--after <podId>]");
|
|
1523
|
-
err(" [--admission intake|external|self | --external] [--wake-budget <n>]");
|
|
1818
|
+
err(" [--admission intake|external|self | --external] [--wake-budget <n>] [--dormant]");
|
|
1524
1819
|
return 2;
|
|
1525
1820
|
}
|
|
1526
1821
|
|
|
@@ -1557,6 +1852,17 @@ async function spawnCmd(
|
|
|
1557
1852
|
return 2;
|
|
1558
1853
|
}
|
|
1559
1854
|
const admission: Admission = declared ?? "self";
|
|
1855
|
+
// Dormancy is not an owner, it is the absence of a first launch — and the tick
|
|
1856
|
+
// does not wait to launch. A `self` Pod is admitted on the very next pass, so
|
|
1857
|
+
// `--dormant` there would promise a stop nothing honours (#43, ADR 0033
|
|
1858
|
+
// amendment: refuse the flag where it cannot mean anything).
|
|
1859
|
+
if (dormant && admission === "self") {
|
|
1860
|
+
err("--dormant needs an owner that arrives later: a `self` pod is admitted by the tick on its next pass, so it would be dormant for a minute at most");
|
|
1861
|
+
err(" --admission intake a listener feeds it: the first batch is its first wake-up");
|
|
1862
|
+
err(" --admission external an outside clock or a human calls `agents-pod wake`");
|
|
1863
|
+
err("or drop --dormant and let its spawn wake-up run now");
|
|
1864
|
+
return 2;
|
|
1865
|
+
}
|
|
1560
1866
|
|
|
1561
1867
|
// Fail fast at the surface (ADR 0012): an unknown id is an exit-2
|
|
1562
1868
|
// configuration error, before any pod record or workspace exists. An
|
|
@@ -1588,6 +1894,7 @@ async function spawnCmd(
|
|
|
1588
1894
|
...(flags.model === undefined ? {} : { model: flags.model }),
|
|
1589
1895
|
...(flags.after === undefined ? {} : { after: flags.after }),
|
|
1590
1896
|
...(wakeBudgetPerDay === undefined ? {} : { wakeBudgetPerDay }),
|
|
1897
|
+
...(dormant ? { dormant: true } : {}),
|
|
1591
1898
|
});
|
|
1592
1899
|
out(record.id);
|
|
1593
1900
|
return 0;
|
|
@@ -1964,6 +2271,8 @@ async function sourceCmd(
|
|
|
1964
2271
|
cmd: source.cmd,
|
|
1965
2272
|
timeoutMs: source.timeoutMs,
|
|
1966
2273
|
cursor: source.cursor ?? null,
|
|
2274
|
+
lastDrainItems: source.lastDrainItems ?? null,
|
|
2275
|
+
unrouted: source.lastDrainUnrouted ?? null,
|
|
1967
2276
|
})),
|
|
1968
2277
|
),
|
|
1969
2278
|
);
|
|
@@ -1974,8 +2283,13 @@ async function sourceCmd(
|
|
|
1974
2283
|
return 0;
|
|
1975
2284
|
}
|
|
1976
2285
|
for (const source of sources) {
|
|
2286
|
+
// The unrouted count rides the line an operator already reads (ADR 0034):
|
|
2287
|
+
// a drain that delivers into nothing is otherwise indistinguishable from a
|
|
2288
|
+
// quiet chat, which is the state this number exists to separate.
|
|
2289
|
+
const unrouted =
|
|
2290
|
+
(source.lastDrainUnrouted ?? 0) === 0 ? "" : ` unrouted ${source.lastDrainUnrouted}`;
|
|
1977
2291
|
out(
|
|
1978
|
-
`${source.name} ${source.cmd} timeout ${source.timeoutMs / 1000}s cursor ${source.cursor ?? "-"}`,
|
|
2292
|
+
`${source.name} ${source.cmd} timeout ${source.timeoutMs / 1000}s cursor ${source.cursor ?? "-"}${unrouted}`,
|
|
1979
2293
|
);
|
|
1980
2294
|
}
|
|
1981
2295
|
return 0;
|
|
@@ -2051,8 +2365,40 @@ async function sourceCmd(
|
|
|
2051
2365
|
return 0;
|
|
2052
2366
|
}
|
|
2053
2367
|
|
|
2368
|
+
case "rewind": {
|
|
2369
|
+
// The one explicit way back past a cursor (ADR 0034): a batch an unrouted
|
|
2370
|
+
// drain swallowed, or a route fixed after the fact. Claims dedupe on id, so
|
|
2371
|
+
// a rewind re-serves exactly what was never claimed; `--to ""` clears the
|
|
2372
|
+
// cursor and makes the adapter decide where to start again.
|
|
2373
|
+
const { flags, rest: words } = parseFlags(rest, ["to"]);
|
|
2374
|
+
const name = words.find((word) => !word.startsWith("--"));
|
|
2375
|
+
if (name === undefined || flags.to === undefined) {
|
|
2376
|
+
err('Usage: agents-pod source rewind <name> --to <cursor|"">');
|
|
2377
|
+
return 2;
|
|
2378
|
+
}
|
|
2379
|
+
const rewound = await withRegistryLock(async () => {
|
|
2380
|
+
const sources = await readSources();
|
|
2381
|
+
const source = sources.find((candidate) => candidate.name === name);
|
|
2382
|
+
if (source === undefined) return false;
|
|
2383
|
+
if (flags.to === "") delete source.cursor;
|
|
2384
|
+
else source.cursor = flags.to;
|
|
2385
|
+
await writeSources(sources);
|
|
2386
|
+
return true;
|
|
2387
|
+
});
|
|
2388
|
+
if (rewound === undefined) {
|
|
2389
|
+
err("registry lock unavailable: cursor not rewound");
|
|
2390
|
+
return 2;
|
|
2391
|
+
}
|
|
2392
|
+
if (!rewound) {
|
|
2393
|
+
err(`Unknown source ${name}`);
|
|
2394
|
+
return 2;
|
|
2395
|
+
}
|
|
2396
|
+
out(flags.to === "" ? `${name} cursor cleared` : `${name} cursor ${flags.to}`);
|
|
2397
|
+
return 0;
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2054
2400
|
default:
|
|
2055
|
-
err(`Unknown source command: ${sub ?? "(none)"}. Try: add | list | rm`);
|
|
2401
|
+
err(`Unknown source command: ${sub ?? "(none)"}. Try: add | list | rm | rewind`);
|
|
2056
2402
|
return 2;
|
|
2057
2403
|
}
|
|
2058
2404
|
}
|
|
@@ -2172,6 +2518,7 @@ async function listenerCmd(
|
|
|
2172
2518
|
inbox: listener.inbox ?? null,
|
|
2173
2519
|
source: listener.source ?? null,
|
|
2174
2520
|
match: listener.match ?? null,
|
|
2521
|
+
chats: listener.chats ?? null,
|
|
2175
2522
|
mode: listener.mode,
|
|
2176
2523
|
template: listener.template ?? null,
|
|
2177
2524
|
project: listener.project ?? null,
|
|
@@ -2193,23 +2540,32 @@ async function listenerCmd(
|
|
|
2193
2540
|
? `template ${listener.template}`
|
|
2194
2541
|
: `pod ${listener.podId}`;
|
|
2195
2542
|
const origin = listener.source === undefined ? listener.inbox : `source:${listener.source}`;
|
|
2196
|
-
|
|
2543
|
+
const filter =
|
|
2544
|
+
listener.chats === undefined ? "" : ` chats ${listener.chats.join(",")}`;
|
|
2545
|
+
out(
|
|
2546
|
+
`${listener.id} ${target} ${origin} claimed ${listener.claimed?.length ?? 0}${filter}`,
|
|
2547
|
+
);
|
|
2197
2548
|
}
|
|
2198
2549
|
return 0;
|
|
2199
2550
|
}
|
|
2200
2551
|
|
|
2201
2552
|
case "add": {
|
|
2202
|
-
const { flags } = parseFlags(
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2553
|
+
const { flags, multiFlags } = parseFlags(
|
|
2554
|
+
rest,
|
|
2555
|
+
[
|
|
2556
|
+
"inbox",
|
|
2557
|
+
"source",
|
|
2558
|
+
"template",
|
|
2559
|
+
"pod",
|
|
2560
|
+
"match",
|
|
2561
|
+
"chat",
|
|
2562
|
+
"max-concurrent",
|
|
2563
|
+
"project",
|
|
2564
|
+
],
|
|
2565
|
+
["chat"],
|
|
2566
|
+
);
|
|
2211
2567
|
const usage =
|
|
2212
|
-
"Usage: agents-pod listener add (--inbox <dir|file> | --source <name>) (--template <file> | --pod <podId>) [--match <substr>] [--max-concurrent <n>] [--project <dir>]";
|
|
2568
|
+
"Usage: agents-pod listener add (--inbox <dir|file> | --source <name>) (--template <file> | --pod <podId>) [--match <substr>] [--chat <chat_id> ...] [--max-concurrent <n>] [--project <dir>]";
|
|
2213
2569
|
if ((flags.inbox === undefined) === (flags.source === undefined)) {
|
|
2214
2570
|
err(`listener add takes exactly one of --inbox or --source. ${usage}`);
|
|
2215
2571
|
return 2;
|
|
@@ -2250,6 +2606,18 @@ async function listenerCmd(
|
|
|
2250
2606
|
return 2;
|
|
2251
2607
|
}
|
|
2252
2608
|
|
|
2609
|
+
// A filter reads the item's group, and a directory item has none, so a rule
|
|
2610
|
+
// it could never satisfy is refused here rather than shipped (ADR 0034).
|
|
2611
|
+
const chats = routingChats(multiFlags.chat);
|
|
2612
|
+
if (chats !== undefined && inbox !== undefined) {
|
|
2613
|
+
const info = await stat(inbox).catch(() => undefined);
|
|
2614
|
+
if (info?.isDirectory()) {
|
|
2615
|
+
err(`--chat filters on a group, and a directory item carries none: ${inbox}`);
|
|
2616
|
+
err(" filter a Source, or a JSONL inbox whose items carry chat_id");
|
|
2617
|
+
return 2;
|
|
2618
|
+
}
|
|
2619
|
+
}
|
|
2620
|
+
|
|
2253
2621
|
let maxConcurrent = 1;
|
|
2254
2622
|
if (flags["max-concurrent"] !== undefined) {
|
|
2255
2623
|
const parsed = Number(flags["max-concurrent"]);
|
|
@@ -2270,6 +2638,7 @@ async function listenerCmd(
|
|
|
2270
2638
|
seen: [],
|
|
2271
2639
|
};
|
|
2272
2640
|
if (flags.match !== undefined) listener.match = flags.match;
|
|
2641
|
+
if (chats !== undefined) listener.chats = chats;
|
|
2273
2642
|
|
|
2274
2643
|
if (flags.template !== undefined) {
|
|
2275
2644
|
// Read once to prove the reference, store the path (ADR 0007): the
|
|
@@ -2327,6 +2696,98 @@ async function listenerCmd(
|
|
|
2327
2696
|
return 0;
|
|
2328
2697
|
}
|
|
2329
2698
|
|
|
2699
|
+
case "edit": {
|
|
2700
|
+
// Claims live in the Listener record (ADR 0034), so re-creating one forgets
|
|
2701
|
+
// the ids it served and the replay window hands them back as new work. The
|
|
2702
|
+
// rule changes in place instead; the target and the claims do not move.
|
|
2703
|
+
const clearChats = rest.includes("--no-chat");
|
|
2704
|
+
const { flags, multiFlags, rest: words } = parseFlags(
|
|
2705
|
+
rest.filter((word) => word !== "--no-chat"),
|
|
2706
|
+
["match", "chat", "max-concurrent", "project"],
|
|
2707
|
+
["chat"],
|
|
2708
|
+
);
|
|
2709
|
+
const id = words.find((word) => !word.startsWith("--"));
|
|
2710
|
+
const usage =
|
|
2711
|
+
"Usage: agents-pod listener edit <listenerId> [--match <substr>] [--chat <chat_id> ... | --no-chat] [--max-concurrent <n>] [--project <dir>]";
|
|
2712
|
+
if (id === undefined) {
|
|
2713
|
+
err(usage);
|
|
2714
|
+
return 2;
|
|
2715
|
+
}
|
|
2716
|
+
if (
|
|
2717
|
+
flags.match === undefined &&
|
|
2718
|
+
multiFlags.chat === undefined &&
|
|
2719
|
+
!clearChats &&
|
|
2720
|
+
flags.project === undefined &&
|
|
2721
|
+
flags["max-concurrent"] === undefined
|
|
2722
|
+
) {
|
|
2723
|
+
err(`listener edit needs at least one rule. ${usage}`);
|
|
2724
|
+
return 2;
|
|
2725
|
+
}
|
|
2726
|
+
const found = (await readListeners()).find((candidate) => candidate.id === id);
|
|
2727
|
+
if (found === undefined) {
|
|
2728
|
+
err(`Unknown listener ${id}`);
|
|
2729
|
+
return 2;
|
|
2730
|
+
}
|
|
2731
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
2732
|
+
let targetProject: string | undefined;
|
|
2733
|
+
if (flags.project !== undefined) {
|
|
2734
|
+
if (found.mode !== "template") {
|
|
2735
|
+
err(`--project needs a template listener: a bound standing Pod keeps its own project. ${usage}`);
|
|
2736
|
+
return 2;
|
|
2737
|
+
}
|
|
2738
|
+
targetProject = resolve(cwd, flags.project);
|
|
2739
|
+
const info = await stat(targetProject).catch(() => undefined);
|
|
2740
|
+
if (info === undefined || !info.isDirectory()) {
|
|
2741
|
+
err(`Project ${targetProject} does not exist or is not a directory`);
|
|
2742
|
+
return 2;
|
|
2743
|
+
}
|
|
2744
|
+
}
|
|
2745
|
+
let maxConcurrent: number | undefined;
|
|
2746
|
+
if (flags["max-concurrent"] !== undefined) {
|
|
2747
|
+
const parsed = Number(flags["max-concurrent"]);
|
|
2748
|
+
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
2749
|
+
err(`--max-concurrent must be a positive integer, got ${flags["max-concurrent"]}`);
|
|
2750
|
+
return 2;
|
|
2751
|
+
}
|
|
2752
|
+
maxConcurrent = parsed;
|
|
2753
|
+
}
|
|
2754
|
+
const chats = clearChats ? [] : routingChats(multiFlags.chat);
|
|
2755
|
+
if (chats !== undefined && chats.length > 0 && found.inbox !== undefined) {
|
|
2756
|
+
const info = await stat(found.inbox).catch(() => undefined);
|
|
2757
|
+
if (info?.isDirectory()) {
|
|
2758
|
+
err(`--chat filters on a group, and a directory item carries none: ${found.inbox}`);
|
|
2759
|
+
return 2;
|
|
2760
|
+
}
|
|
2761
|
+
}
|
|
2762
|
+
const edited = await withRegistryLock(async () => {
|
|
2763
|
+
const current = await readListeners();
|
|
2764
|
+
const listener = current.find((candidate) => candidate.id === id);
|
|
2765
|
+
if (listener === undefined) return false;
|
|
2766
|
+
if (flags.match !== undefined) {
|
|
2767
|
+
if (flags.match === "") delete listener.match;
|
|
2768
|
+
else listener.match = flags.match;
|
|
2769
|
+
}
|
|
2770
|
+
if (chats !== undefined) {
|
|
2771
|
+
if (chats.length === 0) delete listener.chats;
|
|
2772
|
+
else listener.chats = chats;
|
|
2773
|
+
}
|
|
2774
|
+
if (targetProject !== undefined) listener.project = targetProject;
|
|
2775
|
+
if (maxConcurrent !== undefined) listener.maxConcurrent = maxConcurrent;
|
|
2776
|
+
await writeListeners(current);
|
|
2777
|
+
return true;
|
|
2778
|
+
});
|
|
2779
|
+
if (edited === undefined) {
|
|
2780
|
+
err("registry lock unavailable: listener not edited");
|
|
2781
|
+
return 2;
|
|
2782
|
+
}
|
|
2783
|
+
if (!edited) {
|
|
2784
|
+
err(`Unknown listener ${id}`);
|
|
2785
|
+
return 2;
|
|
2786
|
+
}
|
|
2787
|
+
out(id);
|
|
2788
|
+
return 0;
|
|
2789
|
+
}
|
|
2790
|
+
|
|
2330
2791
|
case "rm": {
|
|
2331
2792
|
const id = rest.find((word) => !word.startsWith("--"));
|
|
2332
2793
|
if (!id) {
|
|
@@ -2371,7 +2832,7 @@ async function listenerCmd(
|
|
|
2371
2832
|
}
|
|
2372
2833
|
|
|
2373
2834
|
default:
|
|
2374
|
-
err(`Unknown listener command: ${sub ?? "(none)"}. Try: add | list | rm`);
|
|
2835
|
+
err(`Unknown listener command: ${sub ?? "(none)"}. Try: add | edit | list | rm`);
|
|
2375
2836
|
return 2;
|
|
2376
2837
|
}
|
|
2377
2838
|
}
|
|
@@ -2435,7 +2896,7 @@ export async function main(argv: string[], deps: CliDeps = {}): Promise<number>
|
|
|
2435
2896
|
out("agents-pod — standing Pods across days, one cron-ticked pass");
|
|
2436
2897
|
out("");
|
|
2437
2898
|
out("start here agents-pod doctor what is missing on this machine, and the command that fixes it");
|
|
2438
|
-
out("pods spawn | list | status <id> | logs <id> | wake <id> | cancel <id>");
|
|
2899
|
+
out("pods spawn | list [--all] | status <id> | logs <id> | wake <id> | cancel <id> | rm <id>");
|
|
2439
2900
|
out("records agent | listener | source | deliver | adapter | project | reconcile");
|
|
2440
2901
|
return 0;
|
|
2441
2902
|
}
|
|
@@ -2446,13 +2907,15 @@ export async function main(argv: string[], deps: CliDeps = {}): Promise<number>
|
|
|
2446
2907
|
case "status":
|
|
2447
2908
|
return statusCmd(rest, out, err);
|
|
2448
2909
|
case "list":
|
|
2449
|
-
return listCmd(out);
|
|
2910
|
+
return listCmd(rest, out, err);
|
|
2450
2911
|
case "logs":
|
|
2451
2912
|
return logsCmd(rest, out, err);
|
|
2452
2913
|
case "wake":
|
|
2453
2914
|
return wakeCmd(rest, out, err);
|
|
2454
2915
|
case "cancel":
|
|
2455
2916
|
return cancelCmd(rest, out, err);
|
|
2917
|
+
case "rm":
|
|
2918
|
+
return removeCmd(rest, out, err);
|
|
2456
2919
|
case "reconcile":
|
|
2457
2920
|
return reconcileCmd(deps);
|
|
2458
2921
|
case "agent":
|
|
@@ -2470,7 +2933,7 @@ export async function main(argv: string[], deps: CliDeps = {}): Promise<number>
|
|
|
2470
2933
|
case "doctor":
|
|
2471
2934
|
return doctorCmd(rest, deps, out, err);
|
|
2472
2935
|
default:
|
|
2473
|
-
err(`Unknown command: ${cmd ?? "(none)"}. Try: doctor | spawn | list | status | logs | wake | cancel | reconcile | agent | listener | source | deliver | project`);
|
|
2936
|
+
err(`Unknown command: ${cmd ?? "(none)"}. Try: doctor | spawn | list | status | logs | wake | cancel | rm | reconcile | agent | listener | source | deliver | project`);
|
|
2474
2937
|
return 2;
|
|
2475
2938
|
}
|
|
2476
2939
|
}
|