agent-dag 1.33.19 → 1.33.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/web/index.html
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
6
|
<title>agents-deck</title>
|
|
7
7
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='84' font-size='84'%3E%E2%97%89%3C/text%3E%3C/svg%3E" />
|
|
8
|
-
<script type="module" crossorigin src="/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-DLOmF1C9.js"></script>
|
|
9
9
|
<link rel="stylesheet" crossorigin href="/assets/index-Cpi89XJ8.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-dag",
|
|
3
|
-
"version": "1.33.
|
|
3
|
+
"version": "1.33.21",
|
|
4
4
|
"description": "Live deck of Claude Code and Codex agents — watch parallel subagents fork, call tools, and return on one calm canvas. Also available as npx ccdeck and npx agent-dag.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/server/index.mjs
CHANGED
|
@@ -86,6 +86,15 @@ const pendingTranscriptReads = new Set(); // sessionId currently being read
|
|
|
86
86
|
const modelLastReadAt = new Map(); // sessionId -> ms timestamp (re-read throttle)
|
|
87
87
|
const MODEL_READ_THROTTLE_MS = 2500;
|
|
88
88
|
|
|
89
|
+
/** The cache entry is `{ rootModel, subsSig }`, but `payload.model` is a
|
|
90
|
+
* model *string* — that is the only shape the client's recursive scanner
|
|
91
|
+
* reads. Returns the root model id, or null when the session resolved
|
|
92
|
+
* only subagent models and has no root model yet. */
|
|
93
|
+
export function cachedModelId(cached) {
|
|
94
|
+
const rootModel = cached?.rootModel;
|
|
95
|
+
return typeof rootModel === "string" && rootModel ? rootModel : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
89
98
|
/** Read the main session JSONL. Returns the root model and any
|
|
90
99
|
* legacy-schema subagent models (older CC versions kept subagent blocks
|
|
91
100
|
* inline with `isSidechain:true` + `parentToolUseID`). Current CC versions
|
|
@@ -846,8 +855,8 @@ function pushEvent(raw, source, opts = {}) {
|
|
|
846
855
|
// Synchronous enrichment: if we already know this session's model, stamp
|
|
847
856
|
// it on the payload so the client's recursive scanner picks it up.
|
|
848
857
|
if (raw && typeof raw === "object" && raw.session_id && !raw.model) {
|
|
849
|
-
const
|
|
850
|
-
if (
|
|
858
|
+
const modelId = cachedModelId(modelBySession.get(raw.session_id));
|
|
859
|
+
if (modelId) raw.model = modelId;
|
|
851
860
|
}
|
|
852
861
|
|
|
853
862
|
const seq = nextSeq++;
|
package/src/server/installer.mjs
CHANGED
|
@@ -4,10 +4,11 @@
|
|
|
4
4
|
// Both providers share the discovery dir at ~/.claude/agent-dag/ so a single
|
|
5
5
|
// running server can receive events from either CLI. Re-runs are safe; entries
|
|
6
6
|
// are tagged with __agent-dag and de-duped.
|
|
7
|
-
import { readFile, writeFile, mkdir, copyFile, unlink } from "node:fs/promises";
|
|
7
|
+
import { readFile, writeFile, mkdir, copyFile, unlink, rename, open, stat, chmod } from "node:fs/promises";
|
|
8
8
|
import { existsSync } from "node:fs";
|
|
9
9
|
import { homedir } from "node:os";
|
|
10
10
|
import { join, resolve, dirname } from "node:path";
|
|
11
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
11
12
|
import { fileURLToPath } from "node:url";
|
|
12
13
|
|
|
13
14
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
@@ -128,7 +129,7 @@ async function readSettingsForWrite(p) {
|
|
|
128
129
|
try {
|
|
129
130
|
raw = await readFile(p, "utf8");
|
|
130
131
|
} catch (err) {
|
|
131
|
-
if (err?.code === "ENOENT") return {};
|
|
132
|
+
if (err?.code === "ENOENT") return { settings: {}, raw: null };
|
|
132
133
|
throw unreadableSettings(p, err?.message ?? String(err));
|
|
133
134
|
}
|
|
134
135
|
let parsed;
|
|
@@ -140,7 +141,61 @@ async function readSettingsForWrite(p) {
|
|
|
140
141
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
141
142
|
throw unreadableSettings(p, "top level is not a JSON object");
|
|
142
143
|
}
|
|
143
|
-
return parsed;
|
|
144
|
+
return { settings: parsed, raw };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Rename over an open file is the one thing Windows does differently here. The
|
|
148
|
+
// call itself is atomic (MoveFileEx with MOVEFILE_REPLACE_EXISTING), but it
|
|
149
|
+
// fails outright while another process holds the target open — and a virus
|
|
150
|
+
// scanner or the search indexer opens files the instant they are written, for a
|
|
151
|
+
// few milliseconds at a time. Retrying a sharing violation a handful of times
|
|
152
|
+
// turns that into a successful install. POSIX never hits this path, and a real
|
|
153
|
+
// permission error just costs an extra fraction of a second before it surfaces.
|
|
154
|
+
const RENAME_RETRY_CODES = new Set(["EPERM", "EACCES", "EBUSY"]);
|
|
155
|
+
|
|
156
|
+
async function renameWithRetry(from, to, attempts = 5) {
|
|
157
|
+
for (let attempt = 1; ; attempt++) {
|
|
158
|
+
try {
|
|
159
|
+
await rename(from, to);
|
|
160
|
+
return;
|
|
161
|
+
} catch (err) {
|
|
162
|
+
if (attempt >= attempts || !RENAME_RETRY_CODES.has(err?.code)) throw err;
|
|
163
|
+
await delay(20 * attempt);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Replace a file in a single step readers cannot land inside.
|
|
170
|
+
*
|
|
171
|
+
* The temp file is created beside the target rather than in $TMPDIR, because
|
|
172
|
+
* rename is only atomic within one filesystem and the two are routinely on
|
|
173
|
+
* different ones. It is fsync'd before the rename so that a crash or power loss
|
|
174
|
+
* just after a successful install cannot leave the new directory entry pointing
|
|
175
|
+
* at blocks that were never flushed — the classic file-of-zero-bytes. The pid in
|
|
176
|
+
* the name keeps two decks starting at once off each other's temp file.
|
|
177
|
+
*/
|
|
178
|
+
async function writeFileAtomic(target, text) {
|
|
179
|
+
const tmp = `${target}.agent-dag-${process.pid}.tmp`;
|
|
180
|
+
let handle;
|
|
181
|
+
try {
|
|
182
|
+
handle = await open(tmp, "w");
|
|
183
|
+
await handle.writeFile(text, "utf8");
|
|
184
|
+
await handle.sync();
|
|
185
|
+
} finally {
|
|
186
|
+
await handle?.close();
|
|
187
|
+
}
|
|
188
|
+
try {
|
|
189
|
+
// A rename creates a fresh directory entry, so the old file's mode does not
|
|
190
|
+
// come with it. Carry it over — a settings.json the user chmod'ed to 600 has
|
|
191
|
+
// to stay 600. No-op on Windows, where chmod only toggles the read-only bit.
|
|
192
|
+
const mode = await stat(target).then(s => s.mode, () => null);
|
|
193
|
+
if (mode !== null) await chmod(tmp, mode).catch(() => {});
|
|
194
|
+
await renameWithRetry(tmp, target);
|
|
195
|
+
} catch (err) {
|
|
196
|
+
await unlink(tmp).catch(() => {});
|
|
197
|
+
throw err;
|
|
198
|
+
}
|
|
144
199
|
}
|
|
145
200
|
|
|
146
201
|
async function installHookScript(installDir) {
|
|
@@ -163,14 +218,14 @@ function dedupeOurEntries(group) {
|
|
|
163
218
|
return group.filter(g => !isOurEntry(g));
|
|
164
219
|
}
|
|
165
220
|
|
|
166
|
-
/** Install hooks for a single provider. Returns {settingsPath, hookPath, events}. */
|
|
221
|
+
/** Install hooks for a single provider. Returns {settingsPath, hookPath, events, changed}. */
|
|
167
222
|
export async function installHooks({ provider = "claude" } = {}) {
|
|
168
223
|
const cfg = PROVIDERS[provider];
|
|
169
224
|
if (!cfg) throw new Error(`unknown provider: ${provider}`);
|
|
170
225
|
|
|
171
226
|
// Read before writing anything, so a settings file we cannot parse aborts
|
|
172
227
|
// the install without leaving half of it behind.
|
|
173
|
-
const current = await readSettingsForWrite(cfg.settingsPath);
|
|
228
|
+
const { settings: current, raw: before } = await readSettingsForWrite(cfg.settingsPath);
|
|
174
229
|
|
|
175
230
|
const hookPath = await installHookScript(cfg.hookInstallDir);
|
|
176
231
|
const command = hookCommand(hookPath, provider);
|
|
@@ -186,8 +241,15 @@ export async function installHooks({ provider = "claude" } = {}) {
|
|
|
186
241
|
current.hooks[evt] = cleaned;
|
|
187
242
|
}
|
|
188
243
|
|
|
189
|
-
|
|
190
|
-
|
|
244
|
+
// Every launch reinstalls, and on all but the first the entries are already
|
|
245
|
+
// there and identical. Writing anyway is pure downside: it is one more chance
|
|
246
|
+
// to be interrupted mid-write, and one more window in which a change Claude
|
|
247
|
+
// Code made to the file between our read and our write gets discarded. So
|
|
248
|
+
// compare against the exact bytes we read and, when they match, do nothing.
|
|
249
|
+
const next = JSON.stringify(current, null, 2) + "\n";
|
|
250
|
+
const changed = next !== before;
|
|
251
|
+
if (changed) await writeFileAtomic(cfg.settingsPath, next);
|
|
252
|
+
return { settingsPath: cfg.settingsPath, hookPath, events: cfg.events, provider, changed };
|
|
191
253
|
}
|
|
192
254
|
|
|
193
255
|
export async function uninstallHooks({ provider = "claude" } = {}) {
|
|
@@ -202,7 +264,7 @@ export async function uninstallHooks({ provider = "claude" } = {}) {
|
|
|
202
264
|
if (cleaned.length === 0) delete current.hooks[evt];
|
|
203
265
|
else current.hooks[evt] = cleaned;
|
|
204
266
|
}
|
|
205
|
-
if (changed) await
|
|
267
|
+
if (changed) await writeFileAtomic(cfg.settingsPath, JSON.stringify(current, null, 2) + "\n");
|
|
206
268
|
return { changed, provider, settingsPath: cfg.settingsPath };
|
|
207
269
|
}
|
|
208
270
|
|