@notis_ai/cli 0.2.0-beta.153.2 → 0.2.0-beta.155.1
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/agent-hooks/notis-agent-hook.mjs +203 -106
- package/dist/skill-sync/index.js +134 -72
- package/dist/skill-sync/index.js.map +2 -2
- package/package.json +1 -1
- package/src/command-specs/skills.js +3 -2
- package/src/command-specs/tools.js +5 -0
- package/src/runtime/app-dev-build-supervisor.js +47 -0
- package/src/runtime/app-dev-build.js +41 -0
- package/src/runtime/app-dev-server.js +2 -6
- package/src/runtime/profiles.js +19 -11
- package/src/runtime/skill-sync/cloud-client.ts +5 -3
- package/src/runtime/skill-sync/index.ts +73 -65
- package/src/runtime/skill-sync/symlink-manager.ts +66 -16
- package/src/runtime/skill-sync/types.ts +5 -0
- package/template/packages/sdk/src/interactions/shortcuts.tsx +1 -1
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// A separate process retains ownership of the build group if the CLI crashes.
|
|
2
|
+
// IPC disconnect is the lifetime signal; unlike polling a PID it cannot mistake
|
|
3
|
+
// a reused PID for the original owner.
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
|
|
6
|
+
if (!process.send) throw new Error('Build supervisor requires an IPC owner');
|
|
7
|
+
let build;
|
|
8
|
+
let stopping = false;
|
|
9
|
+
function stop(code = 0) {
|
|
10
|
+
if (stopping) return;
|
|
11
|
+
stopping = true;
|
|
12
|
+
if (!build?.pid) process.exit(code);
|
|
13
|
+
if (process.platform === 'win32') {
|
|
14
|
+
const killer = spawn('taskkill', ['/pid', String(build.pid), '/t', '/f']);
|
|
15
|
+
killer.once('error', () => process.exit(1));
|
|
16
|
+
killer.once('exit', () => process.exit(code));
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const signalGroup = (signal) => {
|
|
20
|
+
try { process.kill(-build.pid, signal); } catch (error) {
|
|
21
|
+
if (error.code !== 'ESRCH') throw error;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
signalGroup('SIGTERM');
|
|
25
|
+
// npm can exit before Vite/esbuild. Keep the supervisor alive until the
|
|
26
|
+
// entire group has received the fallback signal.
|
|
27
|
+
setTimeout(() => {
|
|
28
|
+
signalGroup('SIGKILL');
|
|
29
|
+
process.exit(code);
|
|
30
|
+
}, 1000);
|
|
31
|
+
}
|
|
32
|
+
process.on('disconnect', () => stop());
|
|
33
|
+
process.on('SIGTERM', () => stop());
|
|
34
|
+
process.on('SIGINT', () => stop());
|
|
35
|
+
process.once('message', ({ command, args }) => {
|
|
36
|
+
if (stopping || !process.connected) return;
|
|
37
|
+
build = spawn(command, args, {
|
|
38
|
+
detached: process.platform !== 'win32',
|
|
39
|
+
stdio: 'inherit',
|
|
40
|
+
env: process.env,
|
|
41
|
+
});
|
|
42
|
+
build.once('spawn', () => {
|
|
43
|
+
if (process.connected) process.send({ pid: build.pid }, () => {});
|
|
44
|
+
});
|
|
45
|
+
build.once('error', () => stop(1));
|
|
46
|
+
build.once('exit', (code) => stop(code || 0));
|
|
47
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
// Expose the actual npm group identity so Desktop recovery and diagnostics
|
|
6
|
+
// retain their existing ownership contract. The supervisor owns its lifetime.
|
|
7
|
+
export async function startAppDevBuild(cwd, command = 'npm', args = ['run', 'build', '--', '--watch']) {
|
|
8
|
+
const supervisor = spawn(process.execPath, [fileURLToPath(new URL('./app-dev-build-supervisor.js', import.meta.url))], {
|
|
9
|
+
cwd,
|
|
10
|
+
stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
|
|
11
|
+
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1', NOTIS_DEV: '1' },
|
|
12
|
+
});
|
|
13
|
+
const build = new EventEmitter();
|
|
14
|
+
build.pid = null;
|
|
15
|
+
build.exitCode = null;
|
|
16
|
+
build.signalCode = null;
|
|
17
|
+
build.kill = (signal = 'SIGTERM') => supervisor.kill(signal);
|
|
18
|
+
await new Promise((resolve, reject) => {
|
|
19
|
+
supervisor.once('error', reject);
|
|
20
|
+
supervisor.once('exit', (code, signal) => {
|
|
21
|
+
build.exitCode = code;
|
|
22
|
+
build.signalCode = signal;
|
|
23
|
+
reject(new Error(`Build supervisor exited before startup (${code ?? signal})`));
|
|
24
|
+
build.emit('exit', code, signal);
|
|
25
|
+
});
|
|
26
|
+
supervisor.once('message', ({ pid }) => {
|
|
27
|
+
build.pid = pid;
|
|
28
|
+
resolve();
|
|
29
|
+
});
|
|
30
|
+
supervisor.send({ command, args }, (error) => { if (error) reject(error); });
|
|
31
|
+
});
|
|
32
|
+
return build;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function stopAppDevBuild(build) {
|
|
36
|
+
if (!build || build.exitCode !== null || build.signalCode !== null) return;
|
|
37
|
+
await new Promise((resolve) => {
|
|
38
|
+
build.once('exit', resolve);
|
|
39
|
+
build.kill('SIGTERM');
|
|
40
|
+
});
|
|
41
|
+
}
|
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
readAppDevSessions,
|
|
45
45
|
} from './app-dev-sessions.js';
|
|
46
46
|
import { captureDesktopWatcherOwnership } from './app-dev-process-identity.js';
|
|
47
|
+
import { startAppDevBuild } from './app-dev-build.js';
|
|
47
48
|
|
|
48
49
|
const CONTENT_TYPES = {
|
|
49
50
|
'.js': 'application/javascript; charset=utf-8',
|
|
@@ -1007,12 +1008,7 @@ export async function startAppDevServer({
|
|
|
1007
1008
|
watchManifestInputs(state);
|
|
1008
1009
|
pollForBundleAndWatch(state);
|
|
1009
1010
|
|
|
1010
|
-
const buildProcess =
|
|
1011
|
-
cwd: state.projectDir,
|
|
1012
|
-
detached: process.platform !== 'win32',
|
|
1013
|
-
stdio: 'inherit',
|
|
1014
|
-
env: { ...process.env, NOTIS_DEV: '1' },
|
|
1015
|
-
});
|
|
1011
|
+
const buildProcess = await startAppDevBuild(state.projectDir);
|
|
1016
1012
|
state.buildProcess = buildProcess;
|
|
1017
1013
|
for (let attempt = 0; attempt < 5 && !state.watcherOwnership; attempt += 1) {
|
|
1018
1014
|
state.watcherOwnership = captureDesktopWatcherOwnership({
|
package/src/runtime/profiles.js
CHANGED
|
@@ -485,10 +485,15 @@ export function updateConfig(updater) {
|
|
|
485
485
|
}
|
|
486
486
|
|
|
487
487
|
/**
|
|
488
|
-
* Remove
|
|
489
|
-
*
|
|
490
|
-
*
|
|
491
|
-
*
|
|
488
|
+
* Remove every profile owned by one worktree, plus stale localhost stubs that
|
|
489
|
+
* use one of that worktree's generated profile names. Older builds sometimes
|
|
490
|
+
* lost `dev_workspace_root`, so requiring the ownership marker alone leaves a
|
|
491
|
+
* dead local profile behind after archive.
|
|
492
|
+
*
|
|
493
|
+
* Do this without normalizing the rest of the shared file. Archive cleanup can
|
|
494
|
+
* run before a packaged Desktop upgrade has migrated its legacy `jwt`;
|
|
495
|
+
* preserving unknown/raw fields here keeps that migrate-then-strip handoff
|
|
496
|
+
* intact.
|
|
492
497
|
*/
|
|
493
498
|
export function removeOwnedDevProfiles(profileNames, workspaceRoot) {
|
|
494
499
|
return withConfigWriteLock((configFile) => {
|
|
@@ -502,16 +507,19 @@ export function removeOwnedDevProfiles(profileNames, workspaceRoot) {
|
|
|
502
507
|
return [];
|
|
503
508
|
}
|
|
504
509
|
|
|
510
|
+
const generatedNames = new Set(profileNames);
|
|
505
511
|
const removed = [];
|
|
506
|
-
for (const name of
|
|
507
|
-
|
|
508
|
-
if (
|
|
509
|
-
!profile
|
|
510
|
-
|| typeof profile !== 'object'
|
|
511
|
-
|| profile.dev_workspace_root !== workspaceRoot
|
|
512
|
-
) {
|
|
512
|
+
for (const [name, profile] of Object.entries(raw.profiles)) {
|
|
513
|
+
if (!profile || typeof profile !== 'object') {
|
|
513
514
|
continue;
|
|
514
515
|
}
|
|
516
|
+
|
|
517
|
+
const ownedByWorkspace = profile.dev_workspace_root === workspaceRoot;
|
|
518
|
+
const staleGeneratedLocalProfile = generatedNames.has(name)
|
|
519
|
+
&& !profile.dev_workspace_root
|
|
520
|
+
&& isLocalApiBase(profile.api_base);
|
|
521
|
+
if (!ownedByWorkspace && !staleGeneratedLocalProfile) continue;
|
|
522
|
+
|
|
515
523
|
delete raw.profiles[name];
|
|
516
524
|
removed.push(name);
|
|
517
525
|
if (raw.current_profile === name) raw.current_profile = DEFAULT_PROFILE;
|
|
@@ -72,13 +72,15 @@ export async function updateAgentTargets(
|
|
|
72
72
|
serverUrl: string,
|
|
73
73
|
jwt: string,
|
|
74
74
|
skillId: string,
|
|
75
|
-
targets: AgentTargets
|
|
76
|
-
|
|
77
|
-
|
|
75
|
+
targets: Partial<AgentTargets>,
|
|
76
|
+
expectedUpdatedAt?: string,
|
|
77
|
+
): Promise<{ success: boolean; agent_targets: AgentTargets; updated_at?: string }> {
|
|
78
|
+
return requestJson<{ success: boolean; agent_targets: AgentTargets; updated_at?: string }>(`${serverUrl}/portal_skills/agent-targets`, jwt, {
|
|
78
79
|
method: 'PATCH',
|
|
79
80
|
body: {
|
|
80
81
|
skill_id: skillId,
|
|
81
82
|
agent_targets: targets,
|
|
83
|
+
...(expectedUpdatedAt ? { expected_updated_at: expectedUpdatedAt } : {}),
|
|
82
84
|
},
|
|
83
85
|
});
|
|
84
86
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type {
|
|
2
|
+
AgentTargets,
|
|
2
3
|
LocalSkill,
|
|
3
4
|
NotisSyncState,
|
|
4
5
|
SkillSyncFailure,
|
|
@@ -49,6 +50,7 @@ export interface RunSkillSyncResult {
|
|
|
49
50
|
/** Skills the server rejected (e.g. an invalid SKILL.md description). The rest of
|
|
50
51
|
* the batch still syncs; these are surfaced so the user knows what to fix. */
|
|
51
52
|
failedPushes: SkillSyncFailure[];
|
|
53
|
+
failedLinks?: SkillSyncFailure[];
|
|
52
54
|
}
|
|
53
55
|
|
|
54
56
|
export interface RunSkillSyncOptions {
|
|
@@ -81,6 +83,7 @@ export interface MaterializeCloudSkillsResult {
|
|
|
81
83
|
deleted: number;
|
|
82
84
|
removed: number;
|
|
83
85
|
lastSyncedAt: string | null;
|
|
86
|
+
failedLinks?: SkillSyncFailure[];
|
|
84
87
|
}
|
|
85
88
|
|
|
86
89
|
export interface MaterializeCloudSkillsOptions {
|
|
@@ -174,6 +177,7 @@ function buildSyncState(
|
|
|
174
177
|
pullResponse: SyncPullResponse,
|
|
175
178
|
localSkills: LocalSkill[],
|
|
176
179
|
lastSyncedAt: string | null,
|
|
180
|
+
verifiedAgentLinks: Record<string, Partial<AgentTargets>> = {},
|
|
177
181
|
): NotisSyncState {
|
|
178
182
|
const localSkillMap = toSkillMap(localSkills);
|
|
179
183
|
const skills = Object.fromEntries(
|
|
@@ -185,6 +189,8 @@ function buildSyncState(
|
|
|
185
189
|
cloudId: skill.id,
|
|
186
190
|
folderHash: localSkill?.folderHash || skill.skill_folder_hash || "",
|
|
187
191
|
agentTargets: normalizeAgentTargets(skill.agent_targets),
|
|
192
|
+
verifiedAgentLinks: skill.status === "active" ? verifiedAgentLinks[skill.name] ?? {} : {},
|
|
193
|
+
cloudUpdatedAt: skill.updated_at,
|
|
188
194
|
syncedAt: lastSyncedAt || new Date().toISOString(),
|
|
189
195
|
},
|
|
190
196
|
];
|
|
@@ -268,6 +274,7 @@ async function writePulledSkillsToScopedMirror(
|
|
|
268
274
|
RunSkillSyncDependencies,
|
|
269
275
|
"downloadSkillBundle" | "writeCloudSkillToDisk"
|
|
270
276
|
>,
|
|
277
|
+
failures: SkillSyncFailure[] = [],
|
|
271
278
|
): Promise<number> {
|
|
272
279
|
const localSkillMap = toSkillMap(localSkills);
|
|
273
280
|
const warnSkillSync = (message: string, error: unknown): void => {
|
|
@@ -289,6 +296,8 @@ async function writePulledSkillsToScopedMirror(
|
|
|
289
296
|
})
|
|
290
297
|
) {
|
|
291
298
|
downloaded += 1;
|
|
299
|
+
} else {
|
|
300
|
+
failures.push({ name: cloudSkill.name, error: "Skill content could not be downloaded or written; sync will retry" });
|
|
292
301
|
}
|
|
293
302
|
}
|
|
294
303
|
|
|
@@ -346,27 +355,44 @@ export async function materializeCloudSkillsForLocalShell(
|
|
|
346
355
|
const previousState = await deps.readSyncState(syncPaths);
|
|
347
356
|
|
|
348
357
|
const localSkills = await deps.scanLocalSkills(syncPaths);
|
|
358
|
+
const failedDownloads: SkillSyncFailure[] = [];
|
|
349
359
|
const downloaded = await writePulledSkillsToScopedMirror(
|
|
350
360
|
pullResponse,
|
|
351
361
|
localSkills,
|
|
352
362
|
previousState,
|
|
353
363
|
syncPaths,
|
|
354
364
|
deps,
|
|
365
|
+
failedDownloads,
|
|
355
366
|
);
|
|
356
367
|
const finalLocalSkills = await deps.scanLocalSkills(syncPaths);
|
|
357
368
|
const lastSyncedAt = pullResponse.last_synced_at || new Date().toISOString();
|
|
358
369
|
|
|
359
370
|
const relinkSkillNames = new Set(options.relinkSkillNames || []);
|
|
371
|
+
const failures = [...failedDownloads];
|
|
372
|
+
const verifiedLinks: Record<string, Partial<AgentTargets>> = {};
|
|
373
|
+
for (const skill of pullResponse.skills) {
|
|
374
|
+
const previous = previousState.skills[skill.name];
|
|
375
|
+
if (skill.updated_at && previous?.cloudId === skill.id && previous.cloudUpdatedAt === skill.updated_at) {
|
|
376
|
+
verifiedLinks[skill.name] = { ...previous.verifiedAgentLinks };
|
|
377
|
+
}
|
|
378
|
+
}
|
|
360
379
|
if (relinkSkillNames.size > 0) {
|
|
361
|
-
await deps.syncSymlinks(
|
|
380
|
+
const relinked = await deps.syncSymlinks(
|
|
362
381
|
pullResponse.skills.filter((skill) => relinkSkillNames.has(skill.name)),
|
|
363
382
|
syncPaths.skillsDir,
|
|
364
383
|
{ removeUndesired: false },
|
|
365
384
|
);
|
|
385
|
+
failures.push(...(relinked.failures ?? []).filter(
|
|
386
|
+
(failure) => !failedDownloads.some((download) => download.name === failure.name),
|
|
387
|
+
));
|
|
388
|
+
for (const name of relinkSkillNames) {
|
|
389
|
+
verifiedLinks[name] = relinked.verifiedAgentLinks?.[name] ?? {};
|
|
390
|
+
}
|
|
366
391
|
}
|
|
392
|
+
for (const failure of failedDownloads) delete verifiedLinks[failure.name];
|
|
367
393
|
|
|
368
394
|
await deps.writeSyncState(
|
|
369
|
-
buildSyncState(pullResponse, finalLocalSkills, lastSyncedAt),
|
|
395
|
+
buildSyncState(pullResponse, finalLocalSkills, lastSyncedAt, verifiedLinks),
|
|
370
396
|
syncPaths,
|
|
371
397
|
);
|
|
372
398
|
|
|
@@ -376,6 +402,7 @@ export async function materializeCloudSkillsForLocalShell(
|
|
|
376
402
|
deleted: 0,
|
|
377
403
|
removed: 0,
|
|
378
404
|
lastSyncedAt,
|
|
405
|
+
...(failures.length ? { failedLinks: failures } : {}),
|
|
379
406
|
};
|
|
380
407
|
}
|
|
381
408
|
|
|
@@ -397,6 +424,7 @@ async function deactivateDeletedAgentSkills(
|
|
|
397
424
|
RunSkillSyncDependencies,
|
|
398
425
|
"detectDeletedAgentSymlinks" | "updateAgentTargets" | "pullSkills"
|
|
399
426
|
>,
|
|
427
|
+
failures: SkillSyncFailure[],
|
|
400
428
|
): Promise<number> {
|
|
401
429
|
// First sync (incl. legacy migration) has no reliable "we created this link" signal, so we
|
|
402
430
|
// cannot tell a user deletion apart from a never-created link — skip detection entirely.
|
|
@@ -413,34 +441,6 @@ async function deactivateDeletedAgentSkills(
|
|
|
413
441
|
return 0;
|
|
414
442
|
}
|
|
415
443
|
|
|
416
|
-
// The agent-targets endpoint replaces the whole agent_targets column, so re-read the freshest
|
|
417
|
-
// values right before writing and merge onto them, only flipping the deleted agent. This avoids
|
|
418
|
-
// clobbering a concurrent portal/other-desktop change to a DIFFERENT agent with the stale
|
|
419
|
-
// snapshot from the top-of-sync pull. (A narrow read-modify-write window remains; the endpoint
|
|
420
|
-
// has no partial update.)
|
|
421
|
-
const latestSkillsById = new Map(pullResponse.skills.map((skill) => [skill.id, skill]));
|
|
422
|
-
let fresh: SyncPullResponse | null = null;
|
|
423
|
-
try {
|
|
424
|
-
fresh = await deps.pullSkills(serverUrl, jwt);
|
|
425
|
-
} catch (error) {
|
|
426
|
-
console.warn(
|
|
427
|
-
"[skill-sync] Could not re-pull latest agent targets before deactivation; " +
|
|
428
|
-
"using the top-of-sync snapshot.",
|
|
429
|
-
error,
|
|
430
|
-
);
|
|
431
|
-
}
|
|
432
|
-
if (fresh) {
|
|
433
|
-
// A transport failure may fall back to the already-authorized snapshot, but
|
|
434
|
-
// an explicit legacy denial must abort before any server or local mutation.
|
|
435
|
-
assertSkillsPullAuthorized(fresh);
|
|
436
|
-
for (const skill of fresh.skills) {
|
|
437
|
-
latestSkillsById.set(skill.id, skill);
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
// Group deletions by skill so multiple deleted agents for the SAME skill are flipped in one
|
|
442
|
-
// PATCH. Otherwise each PATCH, rebuilt from the same snapshot, would overwrite the previous one
|
|
443
|
-
// (deleting both claude_code and cursor for one skill would otherwise leave only the last off).
|
|
444
444
|
const agentsBySkill = new Map<string, { skillName: string; agents: Set<DeletedAgentSymlink["agent"]> }>();
|
|
445
445
|
for (const deletion of deletions) {
|
|
446
446
|
const entry = agentsBySkill.get(deletion.skillId) ?? {
|
|
@@ -451,33 +451,39 @@ async function deactivateDeletedAgentSkills(
|
|
|
451
451
|
agentsBySkill.set(deletion.skillId, entry);
|
|
452
452
|
}
|
|
453
453
|
|
|
454
|
-
const
|
|
454
|
+
const fresh = withoutBaseSkills(await deps.pullSkills(serverUrl, jwt));
|
|
455
|
+
assertSkillsPullAuthorized(fresh);
|
|
456
|
+
Object.assign(pullResponse, fresh);
|
|
457
|
+
let needsRefresh = false;
|
|
455
458
|
let deactivated = 0;
|
|
456
459
|
for (const [skillId, { skillName, agents }] of agentsBySkill) {
|
|
457
|
-
const
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
const nextTargets = { ...normalizeAgentTargets(latest.agent_targets) };
|
|
462
|
-
for (const agent of agents) {
|
|
463
|
-
nextTargets[agent] = false;
|
|
464
|
-
}
|
|
460
|
+
const skill = pullResponse.skills.find((item) => item.id === skillId);
|
|
461
|
+
const previous = previousState.skills[skillName];
|
|
462
|
+
if (!skill?.updated_at || previous?.cloudUpdatedAt !== skill.updated_at) continue;
|
|
463
|
+
const patch = Object.fromEntries([...agents].map((agent) => [agent, false]));
|
|
465
464
|
try {
|
|
466
|
-
await deps.updateAgentTargets(serverUrl, jwt, skillId,
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
465
|
+
const saved = await deps.updateAgentTargets(serverUrl, jwt, skillId, patch, skill.updated_at);
|
|
466
|
+
if (saved.success !== true || !saved.updated_at?.trim()
|
|
467
|
+
|| saved.updated_at === skill.updated_at
|
|
468
|
+
|| !['notis', 'claude_code', 'cursor', 'codex'].every((agent) =>
|
|
469
|
+
typeof saved.agent_targets?.[agent as keyof AgentTargets] === 'boolean')
|
|
470
|
+
|| ![...agents].every((agent) => saved.agent_targets[agent] === false)) {
|
|
471
|
+
throw new Error('Assignment update did not return a verified saved revision');
|
|
471
472
|
}
|
|
473
|
+
skill.agent_targets = saved.agent_targets;
|
|
474
|
+
skill.updated_at = saved.updated_at;
|
|
472
475
|
deactivated += agents.size;
|
|
473
476
|
} catch (error) {
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
error,
|
|
478
|
-
);
|
|
477
|
+
needsRefresh = true;
|
|
478
|
+
failures.push({ name: skillName, error: 'Could not save the local agent removal; refreshed saved assignments' });
|
|
479
|
+
console.warn(`[skill-sync] Assignment changed or could not be saved for "${skillName}"; refreshing before reconciliation.`, error);
|
|
479
480
|
}
|
|
480
481
|
}
|
|
482
|
+
if (needsRefresh) {
|
|
483
|
+
const refreshed = withoutBaseSkills(await deps.pullSkills(serverUrl, jwt));
|
|
484
|
+
assertSkillsPullAuthorized(refreshed);
|
|
485
|
+
Object.assign(pullResponse, refreshed);
|
|
486
|
+
}
|
|
481
487
|
return deactivated;
|
|
482
488
|
}
|
|
483
489
|
|
|
@@ -535,6 +541,13 @@ export async function runSkillSync(
|
|
|
535
541
|
.map((skill) => skill.name),
|
|
536
542
|
);
|
|
537
543
|
const protectedSkillNames = new Set([...cloudCuratedSkillNames, ...BASE_SKILL_NAMES]);
|
|
544
|
+
const scopedState = withoutBaseSkillState(await deps.readSyncState(syncPaths));
|
|
545
|
+
const assignmentFailures: SkillSyncFailure[] = [];
|
|
546
|
+
const deactivated = syncSettings.agent_targets_conditional_updates === true
|
|
547
|
+
? await deactivateDeletedAgentSkills(
|
|
548
|
+
serverUrl, jwt, pullResponse, scopedState, scopedState, syncPaths.skillsDir, deps, assignmentFailures,
|
|
549
|
+
)
|
|
550
|
+
: 0;
|
|
538
551
|
const authUserId = decodeJwtSubject(jwt);
|
|
539
552
|
let previousAuthState: NotisSyncState | null = null;
|
|
540
553
|
if (authUserId && authUserId !== syncUserId) {
|
|
@@ -550,7 +563,6 @@ export async function runSkillSync(
|
|
|
550
563
|
});
|
|
551
564
|
const localSkills = (await deps.scanLocalSkills(syncPaths))
|
|
552
565
|
.filter((skill) => !BASE_SKILL_NAMES.has(skill.name));
|
|
553
|
-
const scopedState = withoutBaseSkillState(await deps.readSyncState(syncPaths));
|
|
554
566
|
const previousState = withoutBaseSkillState(applyLegacyFirstRunState(
|
|
555
567
|
localSkills,
|
|
556
568
|
scopedState,
|
|
@@ -560,18 +572,7 @@ export async function runSkillSync(
|
|
|
560
572
|
: previousAuthState)
|
|
561
573
|
: null,
|
|
562
574
|
));
|
|
563
|
-
|
|
564
|
-
// first syncSymlinks pass would recreate the link the user just deleted.
|
|
565
|
-
const deactivated = await deactivateDeletedAgentSkills(
|
|
566
|
-
serverUrl,
|
|
567
|
-
jwt,
|
|
568
|
-
pullResponse,
|
|
569
|
-
previousState,
|
|
570
|
-
scopedState,
|
|
571
|
-
syncPaths.skillsDir,
|
|
572
|
-
deps,
|
|
573
|
-
);
|
|
574
|
-
await deps.syncSymlinks(
|
|
575
|
+
const gatheredSymlinkResult = await deps.syncSymlinks(
|
|
575
576
|
buildLocalSymlinkCandidates(pullResponse, localSkills, previousState),
|
|
576
577
|
syncPaths.skillsDir,
|
|
577
578
|
);
|
|
@@ -607,12 +608,14 @@ export async function runSkillSync(
|
|
|
607
608
|
}
|
|
608
609
|
}
|
|
609
610
|
|
|
611
|
+
const failedDownloads: SkillSyncFailure[] = [];
|
|
610
612
|
const downloaded = await writePulledSkillsToScopedMirror(
|
|
611
613
|
pullResponse,
|
|
612
614
|
localSkills,
|
|
613
615
|
previousState,
|
|
614
616
|
syncPaths,
|
|
615
617
|
deps,
|
|
618
|
+
failedDownloads,
|
|
616
619
|
);
|
|
617
620
|
|
|
618
621
|
const finalLocalSkills = (await deps.scanLocalSkills(syncPaths))
|
|
@@ -621,10 +624,12 @@ export async function runSkillSync(
|
|
|
621
624
|
buildLocalSymlinkCandidates(pullResponse, finalLocalSkills, previousState),
|
|
622
625
|
syncPaths.skillsDir,
|
|
623
626
|
);
|
|
627
|
+
const verifiedLinks = { ...(symlinkResult.verifiedAgentLinks ?? {}) };
|
|
628
|
+
for (const failure of failedDownloads) delete verifiedLinks[failure.name];
|
|
624
629
|
const lastSyncedAt = pullResponse.last_synced_at || new Date().toISOString();
|
|
625
630
|
|
|
626
631
|
await deps.writeSyncState(
|
|
627
|
-
buildSyncState(pullResponse, finalLocalSkills, lastSyncedAt),
|
|
632
|
+
buildSyncState(pullResponse, finalLocalSkills, lastSyncedAt, verifiedLinks),
|
|
628
633
|
syncPaths,
|
|
629
634
|
);
|
|
630
635
|
|
|
@@ -635,9 +640,12 @@ export async function runSkillSync(
|
|
|
635
640
|
downloaded,
|
|
636
641
|
deleted,
|
|
637
642
|
deactivated,
|
|
638
|
-
linked: symlinkResult.linked,
|
|
639
|
-
removed: foreignLinksRemoved + symlinkResult.removed,
|
|
643
|
+
linked: gatheredSymlinkResult.linked + symlinkResult.linked,
|
|
644
|
+
removed: foreignLinksRemoved + gatheredSymlinkResult.removed + symlinkResult.removed,
|
|
640
645
|
skipped: symlinkResult.skipped,
|
|
646
|
+
failedLinks: [...assignmentFailures, ...failedDownloads, ...(symlinkResult.failures ?? []).filter(
|
|
647
|
+
(failure) => !failedDownloads.some((download) => download.name === failure.name),
|
|
648
|
+
)],
|
|
641
649
|
lastSyncedAt,
|
|
642
650
|
failedPushes,
|
|
643
651
|
};
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
NOTIS_SKILL_SYNC_ROOT,
|
|
9
9
|
safeName,
|
|
10
10
|
} from './local-scanner';
|
|
11
|
-
import { normalizeAgentTargets, type CloudSkill, type NotisSyncState } from './types';
|
|
11
|
+
import { normalizeAgentTargets, type AgentTargets, type SkillSyncFailure, type CloudSkill, type NotisSyncState } from './types';
|
|
12
12
|
|
|
13
13
|
const HOME_DIR = os.homedir();
|
|
14
14
|
|
|
@@ -22,6 +22,22 @@ type ExternalAgent = keyof typeof EXTERNAL_AGENT_SKILL_DIRS;
|
|
|
22
22
|
|
|
23
23
|
const EXTERNAL_AGENTS = Object.keys(EXTERNAL_AGENT_SKILL_DIRS) as ExternalAgent[];
|
|
24
24
|
|
|
25
|
+
const AGENT_FAILURE_LABELS: Record<string, string> = {
|
|
26
|
+
claude_code: 'Claude Code',
|
|
27
|
+
cursor: 'Cursor',
|
|
28
|
+
codex: 'Codex',
|
|
29
|
+
legacy: 'legacy ~/.agents/skills',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Failures are shown next to skill names in the Portal and CLI; never leak raw agent ids. */
|
|
33
|
+
function agentFailureLabel(agent: string): string {
|
|
34
|
+
return AGENT_FAILURE_LABELS[agent] ?? agent;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function agentFolderFailureName(agent: string): string {
|
|
38
|
+
return `${agentFailureLabel(agent)} skills folder`;
|
|
39
|
+
}
|
|
40
|
+
|
|
25
41
|
export interface DeletedAgentSymlink {
|
|
26
42
|
skillId: string;
|
|
27
43
|
skillName: string;
|
|
@@ -46,6 +62,8 @@ export interface SymlinkSyncResult {
|
|
|
46
62
|
linked: number;
|
|
47
63
|
removed: number;
|
|
48
64
|
skipped: number;
|
|
65
|
+
verifiedAgentLinks?: Record<string, Partial<AgentTargets>>;
|
|
66
|
+
failures?: SkillSyncFailure[];
|
|
49
67
|
}
|
|
50
68
|
|
|
51
69
|
/** Remove only links into another account's managed mirror. This is safe even
|
|
@@ -128,8 +146,9 @@ async function isManagedSymlink(linkPath: string, managedRoots: string[]): Promi
|
|
|
128
146
|
return managedRoots.some((root) => (
|
|
129
147
|
resolvedTarget === root || resolvedTarget.startsWith(`${root}${path.sep}`)
|
|
130
148
|
));
|
|
131
|
-
} catch {
|
|
132
|
-
return false;
|
|
149
|
+
} catch (error) {
|
|
150
|
+
if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') return false;
|
|
151
|
+
throw error;
|
|
133
152
|
}
|
|
134
153
|
}
|
|
135
154
|
|
|
@@ -146,8 +165,8 @@ async function ensureCorrectSymlink(linkPath: string, targetPath: string): Promi
|
|
|
146
165
|
} else {
|
|
147
166
|
return 'blocked';
|
|
148
167
|
}
|
|
149
|
-
} catch {
|
|
150
|
-
|
|
168
|
+
} catch (error) {
|
|
169
|
+
if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') throw error;
|
|
151
170
|
}
|
|
152
171
|
|
|
153
172
|
const relativePath = path.relative(path.dirname(linkPath), targetPath);
|
|
@@ -166,6 +185,8 @@ async function removeUndesiredManagedSymlinks(
|
|
|
166
185
|
agentDir: string,
|
|
167
186
|
desiredSkills: Set<string>,
|
|
168
187
|
managedRoots: string[],
|
|
188
|
+
failures: SkillSyncFailure[],
|
|
189
|
+
agent: string,
|
|
169
190
|
): Promise<number> {
|
|
170
191
|
let removed = 0;
|
|
171
192
|
try {
|
|
@@ -173,13 +194,19 @@ async function removeUndesiredManagedSymlinks(
|
|
|
173
194
|
const existingEntries = await fs.readdir(agentDir, { withFileTypes: true });
|
|
174
195
|
for (const entry of existingEntries) {
|
|
175
196
|
const entryPath = path.join(agentDir, entry.name);
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
197
|
+
try {
|
|
198
|
+
if (!desiredSkills.has(entry.name) && await isManagedSymlink(entryPath, managedRoots)) {
|
|
199
|
+
await fs.unlink(entryPath);
|
|
200
|
+
removed += 1;
|
|
201
|
+
}
|
|
202
|
+
} catch (error) {
|
|
203
|
+
if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') {
|
|
204
|
+
failures.push({ name: entry.name, error: `${agentFailureLabel(agent)}: could not remove skill link (${(error as Error).message})` });
|
|
205
|
+
}
|
|
179
206
|
}
|
|
180
207
|
}
|
|
181
|
-
} catch {
|
|
182
|
-
|
|
208
|
+
} catch (error) {
|
|
209
|
+
failures.push({ name: agentFolderFailureName(agent), error: `Could not read agent skills directory (${(error as Error).message})` });
|
|
183
210
|
}
|
|
184
211
|
return removed;
|
|
185
212
|
}
|
|
@@ -255,7 +282,9 @@ export async function detectDeletedAgentSymlinks(
|
|
|
255
282
|
continue;
|
|
256
283
|
}
|
|
257
284
|
const previous = previousState.skills[skill.name];
|
|
258
|
-
if (!previous
|
|
285
|
+
if (!previous || previous.cloudId !== skill.id
|
|
286
|
+
|| previous.verifiedAgentLinks?.[agent] !== true
|
|
287
|
+
|| !skill.updated_at || previous.cloudUpdatedAt !== skill.updated_at) {
|
|
259
288
|
continue;
|
|
260
289
|
}
|
|
261
290
|
|
|
@@ -293,10 +322,12 @@ export async function syncSymlinks(
|
|
|
293
322
|
): Promise<SymlinkSyncResult> {
|
|
294
323
|
await fs.mkdir(skillsDir, { recursive: true });
|
|
295
324
|
|
|
296
|
-
const result: SymlinkSyncResult = {
|
|
325
|
+
const result: Required<SymlinkSyncResult> = {
|
|
297
326
|
linked: 0,
|
|
298
327
|
removed: 0,
|
|
299
328
|
skipped: 0,
|
|
329
|
+
verifiedAgentLinks: {},
|
|
330
|
+
failures: [],
|
|
300
331
|
};
|
|
301
332
|
const agentSkillDirs = { ...defaultAgentSkillDirs(skillsDir), ...options.agentSkillDirs };
|
|
302
333
|
const legacyGlobalSkillsDir = options.legacyGlobalSkillsDir || LEGACY_AGENTS_SKILLS_DIR;
|
|
@@ -336,23 +367,36 @@ export async function syncSymlinks(
|
|
|
336
367
|
continue;
|
|
337
368
|
}
|
|
338
369
|
|
|
339
|
-
|
|
370
|
+
try {
|
|
371
|
+
await fs.mkdir(agentDir, { recursive: true });
|
|
372
|
+
} catch (error) {
|
|
373
|
+
result.failures.push({ name: agentFolderFailureName(agent), error: `Could not create agent skills directory (${(error as Error).message})` });
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
340
376
|
|
|
341
377
|
const desiredSkills = desiredByAgent[agent];
|
|
342
378
|
if (options.removeUndesired !== false) {
|
|
343
|
-
result.removed += await removeUndesiredManagedSymlinks(agentDir, desiredSkills, managedRoots);
|
|
379
|
+
result.removed += await removeUndesiredManagedSymlinks(agentDir, desiredSkills, managedRoots, result.failures, agent);
|
|
344
380
|
}
|
|
345
381
|
|
|
346
382
|
for (const skillName of desiredSkills) {
|
|
347
383
|
const targetPath = path.join(skillsDir, skillName);
|
|
348
384
|
const linkPath = path.join(agentDir, safeName(skillName, agentDir));
|
|
349
385
|
try {
|
|
350
|
-
await fs.
|
|
386
|
+
if (!(await fs.stat(path.join(targetPath, "SKILL.md"))).isFile()) throw new Error("Missing SKILL.md");
|
|
351
387
|
} catch {
|
|
352
388
|
result.skipped += 1;
|
|
389
|
+
result.failures.push({ name: skillName, error: `${agentFailureLabel(agent)}: SKILL.md is missing or unreadable` });
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
let syncOutcome;
|
|
393
|
+
try {
|
|
394
|
+
syncOutcome = await ensureCorrectSymlink(linkPath, targetPath);
|
|
395
|
+
} catch (error) {
|
|
396
|
+
result.skipped += 1;
|
|
397
|
+
result.failures.push({ name: skillName, error: `${agentFailureLabel(agent)}: could not create skill link (${(error as Error).message})` });
|
|
353
398
|
continue;
|
|
354
399
|
}
|
|
355
|
-
const syncOutcome = await ensureCorrectSymlink(linkPath, targetPath);
|
|
356
400
|
if (syncOutcome === 'linked') {
|
|
357
401
|
result.linked += 1;
|
|
358
402
|
} else if (syncOutcome === 'blocked') {
|
|
@@ -360,9 +404,13 @@ export async function syncSymlinks(
|
|
|
360
404
|
`[skill-sync] Could not link "${skillName}" for ${agent}: non-symlink entry blocks ${linkPath}`,
|
|
361
405
|
);
|
|
362
406
|
result.skipped += 1;
|
|
407
|
+
result.failures.push({ name: skillName, error: `${agentFailureLabel(agent)}: an existing file or folder blocks the skill link` });
|
|
363
408
|
} else {
|
|
364
409
|
result.skipped += 1;
|
|
365
410
|
}
|
|
411
|
+
if (syncOutcome !== "blocked") {
|
|
412
|
+
result.verifiedAgentLinks[skillName] = { ...result.verifiedAgentLinks[skillName], [agent]: true };
|
|
413
|
+
}
|
|
366
414
|
}
|
|
367
415
|
}
|
|
368
416
|
|
|
@@ -376,6 +424,8 @@ export async function syncSymlinks(
|
|
|
376
424
|
legacyGlobalSkillsDir,
|
|
377
425
|
new Set(),
|
|
378
426
|
managedRoots,
|
|
427
|
+
result.failures,
|
|
428
|
+
'legacy',
|
|
379
429
|
);
|
|
380
430
|
}
|
|
381
431
|
|