clay-server 5.5.0-beta.2 → 5.5.0-beta.4
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/lib/clay-product-control-input.js +2 -2
- package/lib/clay-product-control-tools.js +1 -1
- package/lib/clay-product-control.js +4 -3
- package/lib/daemon-project-access.js +10 -2
- package/lib/daemon-projects.js +16 -4
- package/lib/daemon-worktree-controls.js +171 -0
- package/lib/daemon.js +14 -63
- package/lib/personal-provider-install.js +85 -0
- package/lib/personal-provider-settings.js +94 -0
- package/lib/project-logs-service.js +2 -2
- package/lib/project-pair-task-control.js +1 -1
- package/lib/project-pending-message-queue.js +6 -2
- package/lib/project-product-control.js +1 -0
- package/lib/project-session-actions.js +46 -3
- package/lib/project-session-pair.js +2 -0
- package/lib/project-sessions.js +11 -3
- package/lib/project-user-message.js +5 -1
- package/lib/project-vendor-login.js +95 -32
- package/lib/project-worker-auth.js +71 -0
- package/lib/project-worker-conversation.js +11 -2
- package/lib/project.js +9 -0
- package/lib/public/app.js +22 -0
- package/lib/public/css/icon-strip.css +5 -0
- package/lib/public/css/overlays.css +18 -0
- package/lib/public/css/settings-shell.css +13 -0
- package/lib/public/css/settings-ui.css +9 -0
- package/lib/public/css/split-view-layout.css +16 -11
- package/lib/public/modules/agent-behavior-nav.js +1 -0
- package/lib/public/modules/agent-message-bubbles.js +33 -4
- package/lib/public/modules/app-messages.js +7 -1
- package/lib/public/modules/app-projects.js +3 -4
- package/lib/public/modules/clay-control-copy.js +1 -0
- package/lib/public/modules/clay-tool-presentation.js +1 -1
- package/lib/public/modules/generation-stop.js +4 -4
- package/lib/public/modules/pane-activation.js +2 -2
- package/lib/public/modules/pane-bridge.js +4 -2
- package/lib/public/modules/personal-provider-context.js +11 -0
- package/lib/public/modules/project-settings.js +1 -1
- package/lib/public/modules/settings-section-nav.js +58 -0
- package/lib/public/modules/settings-shell-registry.js +7 -4
- package/lib/public/modules/settings-shell.js +17 -6
- package/lib/public/modules/sidebar-projects.js +23 -9
- package/lib/public/modules/split-pane-force-stop.js +47 -0
- package/lib/public/modules/split-pane-header-state.js +11 -1
- package/lib/public/modules/split-pane-renderer.js +2 -2
- package/lib/public/modules/split-view-workers.js +32 -17
- package/lib/public/modules/stt-settings.js +21 -4
- package/lib/public/modules/user-settings-layout.js +22 -18
- package/lib/public/modules/user-settings-provider-install.js +42 -0
- package/lib/public/modules/user-settings-providers.js +102 -0
- package/lib/public/modules/user-settings-voice.js +50 -41
- package/lib/public/modules/vendor-login-browser.js +1 -1
- package/lib/public/modules/vendor-login.js +72 -12
- package/lib/public/modules/worker-activity-indicator.js +49 -0
- package/lib/public/modules/worker-message-previews.js +47 -11
- package/lib/public/modules/worker-stop-controls.js +36 -0
- package/lib/public/modules/worktree-controls.js +86 -0
- package/lib/public/pane-assets/{app-SEMVZ4AY.js → app-PV3SEOTX.js} +1 -1
- package/lib/public/pane-assets/{chunk-C6P7PICY.js → chunk-2G6DB3ES.js} +1 -1
- package/lib/public/pane-assets/{chunk-BNNPJJVG.js → chunk-4WYURTPH.js} +1 -1
- package/lib/public/pane-assets/{chunk-E4VM2IDB.js → chunk-53KRXJDL.js} +1 -1
- package/lib/public/pane-assets/{chunk-4YE4RU27.js → chunk-AMZ7P526.js} +1 -1
- package/lib/public/pane-assets/chunk-BK2NRZFM.js +1 -0
- package/lib/public/pane-assets/{chunk-5UBN6GR7.js → chunk-D3WR2TYZ.js} +1 -1
- package/lib/public/pane-assets/chunk-E7D5A27I.js +1 -0
- package/lib/public/pane-assets/{chunk-CQNPEH2M.js → chunk-FB5B2WYF.js} +1 -1
- package/lib/public/pane-assets/chunk-FDBSE7BE.js +174 -0
- package/lib/public/pane-assets/{chunk-FK3YLGAP.js → chunk-FHB76KH2.js} +1 -1
- package/lib/public/pane-assets/{chunk-YR5GH3GI.js → chunk-FNGO6432.js} +1 -1
- package/lib/public/pane-assets/chunk-HF7AK56Q.js +1 -0
- package/lib/public/pane-assets/chunk-ILVRGTDF.js +1 -0
- package/lib/public/pane-assets/{chunk-QWQB2YRF.js → chunk-L3SFMSLP.js} +1 -1
- package/lib/public/pane-assets/{chunk-QBCM7QEA.js → chunk-PAY3A4JS.js} +1 -1
- package/lib/public/pane-assets/chunk-QVHC324H.js +1 -0
- package/lib/public/pane-assets/chunk-SFANEWZA.js +1 -0
- package/lib/public/pane-assets/{chunk-M5CWIIIT.js → chunk-SGMEO3P7.js} +1 -1
- package/lib/public/pane-assets/{chunk-YVVT3CTV.js → chunk-TK446XFF.js} +1 -1
- package/lib/public/pane-assets/{chunk-U7EHYRJZ.js → chunk-TU3AHPSN.js} +1 -1
- package/lib/public/pane-assets/chunk-US5VELGB.js +1 -0
- package/lib/public/pane-assets/{chunk-K4PAN77B.js → chunk-UVTS6GUM.js} +1 -1
- package/lib/public/pane-assets/{chunk-XYZGVYWW.js → chunk-X45WI2U7.js} +1 -1
- package/lib/public/pane-assets/manifest.json +401 -393
- package/lib/public/pane-assets/style-563D4UN6.css +1 -0
- package/lib/sdk-bridge.js +40 -6
- package/lib/sdk-force-stop.js +62 -0
- package/lib/sdk-message-processor.js +2 -1
- package/lib/server-worktree-controls.js +34 -0
- package/lib/server.js +11 -1
- package/lib/session-force-stop-support.js +15 -0
- package/lib/sessions.js +6 -1
- package/lib/terminal-manager.js +1 -1
- package/lib/terminal.js +13 -1
- package/lib/vendor-login-browser-flow.js +1 -1
- package/lib/worker-activity.js +26 -0
- package/lib/worker-message-previews.js +12 -7
- package/lib/worktree-branch.js +57 -0
- package/lib/worktree-identity.js +39 -0
- package/lib/worktree-preferences.js +25 -0
- package/lib/worktree-ref-transaction.js +36 -0
- package/lib/yoke/adapters/antigravity.js +3 -0
- package/lib/yoke/force-termination.js +26 -0
- package/lib/yoke/interface.js +14 -0
- package/lib/yoke/process-force-termination.js +24 -0
- package/lib/yoke/provider-install-recipes.js +20 -0
- package/lib/yoke/vendor-registry.js +20 -1
- package/package.json +1 -1
- package/lib/public/pane-assets/chunk-6AGSOAOD.js +0 -1
- package/lib/public/pane-assets/chunk-AATAIAXL.js +0 -1
- package/lib/public/pane-assets/chunk-D3CE5FZL.js +0 -1
- package/lib/public/pane-assets/chunk-GKSLBGVE.js +0 -168
- package/lib/public/pane-assets/chunk-J5PTSKJ6.js +0 -1
- package/lib/public/pane-assets/chunk-QPBO6ID4.js +0 -1
- package/lib/public/pane-assets/chunk-RA6VUJTY.js +0 -1
- package/lib/public/pane-assets/chunk-XOZUM5ET.js +0 -1
- package/lib/public/pane-assets/style-DZX6JAMF.css +0 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
var ACTIONS = ['rename', 'stop', 'model', 'effort', 'permission', 'delete', 'fork', 'rewind', 'vendor', 'vendor_new_session', 'runtime', 'queue_add', 'queue_edit', 'queue_cancel', 'queue_reorder'];
|
|
1
|
+
var ACTIONS = ['rename', 'stop', 'force_stop', 'model', 'effort', 'permission', 'delete', 'fork', 'rewind', 'vendor', 'vendor_new_session', 'runtime', 'queue_add', 'queue_edit', 'queue_cancel', 'queue_reorder'];
|
|
2
2
|
var KEYS = ['targetToken', 'action', 'value', 'why', 'requestId', 'text', 'messageId', 'ids'];
|
|
3
3
|
function string(value, max) { return typeof value === 'string' && value.trim() && value.length <= max && !/[\u0000-\u001f\u007f]/.test(value); }
|
|
4
4
|
function messageId(value) { return typeof value === 'string' && /^pm_[1-9][0-9]{0,15}$/.test(value); }
|
|
@@ -23,7 +23,7 @@ function validate(args) {
|
|
|
23
23
|
function description(args) {
|
|
24
24
|
if (args.action === 'vendor_new_session') return 'Create a new private GUI session using provider ' + args.value + '. The original conversation and its provider thread remain unchanged. No history is migrated and no model execution is started.';
|
|
25
25
|
if (args.action === 'runtime') return 'Return this legacy terminal session to GUI chat: ' + args.value + '. Wait for confirmed terminal shutdown, retain the same provider thread, and start no execution. New terminal presentation is unavailable.';
|
|
26
|
-
var labels = { rename: 'Rename this conversation to ', model: "Change this conversation's model to ", effort: 'Change reasoning effort to ', permission: 'Change the permission mode to ', delete: 'Permanently delete this Driver and its owned Worker history', fork: 'Fork this conversation at checkpoint ', rewind: 'Rewind conversation history to checkpoint ', vendor: 'Select the provider for this fresh GUI Driver: ' };
|
|
26
|
+
var labels = { force_stop: 'Force terminate this Driver and every owned Worker runtime only when all targets support it, and pause queued messages. Runtime exit must be confirmed; external commands and requests may still be running', rename: 'Rename this conversation to ', model: "Change this conversation's model to ", effort: 'Change reasoning effort to ', permission: 'Change the permission mode to ', delete: 'Permanently delete this Driver and its owned Worker history', fork: 'Fork this conversation at checkpoint ', rewind: 'Rewind conversation history to checkpoint ', vendor: 'Select the provider for this fresh GUI Driver: ' };
|
|
27
27
|
if (args.action === 'queue_add') return 'Queue this exact message for the project Driver. It may run immediately when idle; it will not resume a stopped Driver.\nMessage:\n' + args.text;
|
|
28
28
|
if (args.action === 'queue_edit') return 'Replace the text of queued message ' + args.messageId + '. Existing attachments are preserved.\nNew message:\n' + args.text;
|
|
29
29
|
if (args.action === 'queue_cancel') return 'Cancel queued message ' + args.messageId + '. Already delivered work is not undone.';
|
|
@@ -46,7 +46,7 @@ function getToolDefs(bound) {
|
|
|
46
46
|
tool("inspect_project_control", "Inspect an exact ordinary-project lifecycle intent before review. Create/register are private. Remove unregisters and stops runtimes but preserves files/history. Existing project changes require ownership. Clay/Mates/worktrees and Server/User Settings are excluded. Environment values appear only in the owner review. Retry an identical intent with the same requestId after inspection; durable receipts prevent duplicate creation. Failed persisted operations expose operationId. Inspect reconcile to complete their exact saved change, or recover_registration to cancel a pending removal and restore access with execution paused. Both require a fresh owner review. Completed transfer retries return only the original bounded outcome to the same canonical source.", { action: z.enum(require("./clay-project-lifecycle").ACTIONS), projectSlug: z.string().optional(), name: z.string().optional(), path: z.string().optional(), value: z.union([z.string(), z.array(z.string())]).optional() }, "inspectProject"),
|
|
47
47
|
tool("control_project_lifecycle", "Request the inspected project change and explain why. Only authenticated human approval of the exact revision authorizes the action. Reuse requestId for an identical retry; do not repeat an unknown or partial result with a new ID. Removal awaits runtime shutdown and retains files/history. OS filesystem sharing is unavailable. The exact combined value, target and reason must fit the existing 2000-character owner review.", { targetToken: z.string(), requestId: z.string(), why: z.string() }, "actProject"),
|
|
48
48
|
tool("inspect_session_control", "Inspect an exact owned project Driver session before changing it. Returns a short-lived target revision token, model catalog and runtime state.", { projectSlug: z.string(), sessionRef: z.string() }, "inspect"),
|
|
49
|
-
tool("control_project_session", "Request an explicit change to an inspected project Driver. Explain what and why. Clay asks the authenticated owner to review the exact change; confirmed:true cannot authorize it. Reuse requestId only for an identical retry. Failed receipts may report an applied or unknown provider effect, or a fork retained in memory; inspect those effects before another action and never create another fork to retry a save. Queue actions inspect, persist and update the real Driver queue, never resume Stop, and do not directly execute Workers. Stop requests return queued until the runtime settles. Use reviewed project assignment/follow-up for task execution; never resume a stopped session.", { targetToken: z.string(), action: z.enum(input.ACTIONS), value: z.string().describe("Rename title, model, effort, permission mode, or exact checkpoint UUID for fork/chat rewind. Rewind restores conversation only; vendor selects an installed provider only on a fresh idle GUI Driver; vendor_new_session creates a blank private GUI session for an established conversation and retains the original without history migration or execution; runtime accepts gui to close a legacy Claude terminal after confirmed exit. New TUI presentation is unavailable.").optional(), why: z.string(), requestId: z.string(), text: z.string().max(1200).describe("Exact message text for queue_add or queue_edit. The full text is reviewed by the owner.").optional(), messageId: z.string().describe("Exact queued message ID from inspection, for edit or cancel.").optional(), ids: z.array(z.string()).max(100).describe("All owned pending message IDs in desired order for queue_reorder.").optional() }, "act"),
|
|
49
|
+
tool("control_project_session", "Request an explicit change to an inspected project Driver. Explain what and why. Clay asks the authenticated owner to review the exact change; confirmed:true cannot authorize it. Reuse requestId only for an identical retry. Failed receipts may report an applied or unknown provider effect, or a fork retained in memory; inspect those effects before another action and never create another fork to retry a save. Queue actions inspect, persist and update the real Driver queue, never resume Stop, and do not directly execute Workers. Stop requests return queued until the runtime settles. Reviewed force_stop requires explicit YOKE force termination support for the Driver and every owned Worker before any effect. It pauses queues and reports exact query runtime exit or unconfirmed termination; external commands and requests remain unconfirmed. Use reviewed project assignment/follow-up for task execution; never resume a stopped session.", { targetToken: z.string(), action: z.enum(input.ACTIONS), value: z.string().describe("Rename title, model, effort, permission mode, or exact checkpoint UUID for fork/chat rewind. Rewind restores conversation only; vendor selects an installed provider only on a fresh idle GUI Driver; vendor_new_session creates a blank private GUI session for an established conversation and retains the original without history migration or execution; runtime accepts gui to close a legacy Claude terminal after confirmed exit. New TUI presentation is unavailable.").optional(), why: z.string(), requestId: z.string(), text: z.string().max(1200).describe("Exact message text for queue_add or queue_edit. The full text is reviewed by the owner.").optional(), messageId: z.string().describe("Exact queued message ID from inspection, for edit or cancel.").optional(), ids: z.array(z.string()).max(100).describe("All owned pending message IDs in desired order for queue_reorder.").optional() }, "act"),
|
|
50
50
|
];
|
|
51
51
|
}
|
|
52
52
|
module.exports = { getToolDefs: getToolDefs };
|
|
@@ -83,7 +83,7 @@ function attachClayProductControl(ctx) {
|
|
|
83
83
|
return { screen: { tools: ["read_owner_screen", "inspect_owner_screen_change", "control_owner_screen"], scope: "Exact authenticated owner UI connection and owned conversation navigation; delivery and client acceptance do not prove rendering. screen_context lists server-known connections. split_views reads saved configuration and challenges one selected live project window for visible/cached pane bindings, layout and focus. Typed reviewed split_view changes share human persistence and navigation paths. Owner notification read and reviewed exact dismissal use the existing queue. No arbitrary messages or Settings. Split view create/update/rename/separate/member transfer/folder/Favorite/open/focus are supported in the selected live project window; zero-to-eight session composition and bounded v1 workspace updates for existing tool instances share the human View service. File Follow/Pin/close, placement and layout are supported; new or rebound tool resources remain unavailable through this control. Saved descriptors and client observations are distinct. Account-wide workbench mode remains excluded.", receipts: "Query-local attempts survive catalog rebinding only. Read delivery status; never blindly repeat an uncertain delivery." }, actions: ACTIONS, scheduling: { tools: ["begin_schedule_control_interview", "read_schedule_controls", "inspect_schedule_change", "control_schedules"], mates: "Own and same-owner private Mate conversation list/read/create/update/cancel. No pause/resume/run-now action exists for logical Mate schedules.", projects: "Selected owned ordinary-project Driver list/read, proposed creation requiring final workbench Create task, reviewed update/pause/resume/cancel/run through existing scheduling services. Run is queued, never task completion; Workers remain owned by the project scheduled executor.", receipts: "Dated request IDs; 24-hour receipt window, at most 100 retained receipts on the source conversation. Interrupted writes require inspection, never blind execution." }, projectRecords: { tools: ["read_project_records", "inspect_project_record_change", "control_project_records"], scope: "Selected owned ordinary projects, including worktree context. Exact owner review; query-local bounded retry receipts.", unsupported: ["githubFiling", "workSessionLaunch", "scheduledResultMutation"], routing: "Use the existing reviewed project assignment or follow-up tools for Driver execution. Record changes never run Git commits." }, mateKnowledge: { tools: ["read_mate_knowledge_control", "inspect_mate_knowledge_change", "control_mate_knowledge"], review: "Exact owner-reviewed Markdown and typed database/record operations; serialized intents are limited to 1400 characters within the existing review card.", unsupported: ["hardDatabaseOrRecordDeletion", "legacyJsonlWrites", "promotionOrSharing", "arbitraryFilesystemOrSql", "userKnowledgeCuration"] }, mateLifecycle: { actions: ["list", "inspect", "profile", "delete"], profileFields: ["name", "bio", "workspaceColor", "avatarCatalogId"], deletion: "Idle owner-private Mates only; canonical Clay and primary Mates are protected. Stored transcripts and provider history are retained. Pending runtime cleanup without closure proof requires operator recovery.", excluded: ["knowledge", "directMessages", "schedules", "sharing", "ownershipTransfer", "model"] }, mateChat: { tools: ["read_mate_chat_control", "inspect_mate_chat_change", "control_mate_chat"], actions: ["send", "stop", "model", "vendor", "effort"], scope: "Exact existing same-owner private Mate conversation; vendor selection requires a fresh idle conversation. Delegated messages are untrusted and never owner input. Dated durable retry receipts, no implicit resume or thread migration." }, workspace: { tools: ["read_project_workspace", "read_workspace_receipt", "inspect_workspace_change", "control_project_workspace"], files: "Bounded list/read and exact reviewed writes to existing regular text files within a selected owned ordinary project. Symlinks, hardlinks, managed settings, create/delete/move and arbitrary filesystem operations are excluded.", git: "Status and changed-file diff; exact reviewed selected-path stage/unstage only. No linked Git metadata/submodules, commits, pull/push, PR operations or arbitrary Git arguments.", terminal: "Owner shell list/scrollback and reviewed blank shell creation, rename, resize and acknowledged close. No input, commands, coding-agent/login terminals or Worker control." }, approval: "All changes except Stop require an authenticated owner review of the exact action and target revision.",
|
|
84
84
|
vendorUsage: { tools: ["read_vendor_usage"], scope: "Read-only usage of the owner's own vendor accounts and sessions in one owned project, through the same service as the top bar; the owner's identity only, no refresh, credentials, URLs, commands or settings. Account figures keep each vendor's units; unreadable usage is reported with its reason." },
|
|
85
85
|
taskExecution: "Use propose_project_assignment or propose_project_follow_up to route execution through a project Driver. A stopped session cannot be resumed by this control layer.",
|
|
86
|
-
unsupported: { archive: "No authoritative session archive service exists.", send: "Use reviewed Driver assignment or follow-up.", runtime: "New TUI presentation and cross-provider thread migration are unavailable. runtime=gui closes a legacy Claude terminal with acknowledged exit. vendor_new_session creates a private blank GUI session and retains the original without execution.", projectLifecycle: ctx.projectLifecycle ? "Create, register, rename, remove registration, project environment and owner access controls use exact human review. OS filesystem sharing changes are unavailable through this service." : "Unavailable in this runtime." },
|
|
86
|
+
unsupported: { personalProviders: "Personal AI provider installation discovery, reviewed supported installation, sign-in and audio transcription API keys are available only in authenticated human settings. No canonical credential, provider installation or sign-in operation is exposed.", worktrees: "Worktree icon edits, exclusion and disk deletion are available only through human product controls. Restoring exclusions and reconciling pending worktree removals have no in-app operation.", archive: "No authoritative session archive service exists.", send: "Use reviewed Driver assignment or follow-up.", runtime: "New TUI presentation and cross-provider thread migration are unavailable. runtime=gui closes a legacy Claude terminal with acknowledged exit. vendor_new_session creates a private blank GUI session and retains the original without execution.", projectLifecycle: ctx.projectLifecycle ? "Create, register, rename, remove registration, project environment and owner access controls use exact human review. OS filesystem sharing changes are unavailable through this service." : "Unavailable in this runtime." },
|
|
87
87
|
excluded: ["serverSettings", "userSettings", "workerCreation", "pairOrchestration"] };
|
|
88
88
|
}
|
|
89
89
|
function inspect(args) {
|
|
@@ -96,7 +96,7 @@ function attachClayProductControl(ctx) {
|
|
|
96
96
|
var sm = live.project.getSessionManager(), vendor = target.vendor || sm.defaultVendor || "claude";
|
|
97
97
|
var caps = (sm.capabilitiesByVendor || {})[vendor] || {};
|
|
98
98
|
return { queue: queue, availableVendors: sm.availableVendors || [], runtimeMode: target.runtimeMode || target.mode || "gui", runtimeTransition: "Only legacy Claude terminal to GUI chat is supported; terminal exit must be acknowledged. No execution starts.", vendorTransition: "vendor selects a fresh idle GUI Driver provider; vendor_new_session creates a private blank session and retains an established original without migrating history.", targetToken: token, projectSlug: args.projectSlug, sessionRef: args.sessionRef, title: target.title || "New Session", vendor: vendor, model: target.model || null, effort: target.effort || null, permissionMode: target.permissionMode || "default", processing: !!target.isProcessing, stopped: !!target.taskStopRequested, queuePaused: !!target._pendingMessageDrainPaused,
|
|
99
|
-
providerCapabilities: { fork: caps.fork === true, conversationRewind: caps.rollback === true || caps.rewind === true, effort: caps.effort === true, modelCatalogAvailable: !!((sm.modelsByVendor || {})[vendor] || []).length }, deletionCount: scope(live.project, target).length, checkpoints: (target.messageUUIDs || []).map(function (entry) { return { uuid: entry.uuid, type: entry.type }; }), actions: ACTIONS, models: (sm.modelsByVendor || {})[vendor] || [], permissions: require("./session-permission-mode").clientState(target, sm.currentPermissionMode, vendor), expiresInSeconds: 300 };
|
|
99
|
+
providerCapabilities: { forceTermination: require('./session-force-stop-support').describe(sm, target), fork: caps.fork === true, conversationRewind: caps.rollback === true || caps.rewind === true, effort: caps.effort === true, modelCatalogAvailable: !!((sm.modelsByVendor || {})[vendor] || []).length }, deletionCount: scope(live.project, target).length, checkpoints: (target.messageUUIDs || []).map(function (entry) { return { uuid: entry.uuid, type: entry.type }; }), actions: ACTIONS, models: (sm.modelsByVendor || {})[vendor] || [], permissions: require("./session-permission-mode").clientState(target, sm.currentPermissionMode, vendor), expiresInSeconds: 300 };
|
|
100
100
|
}
|
|
101
101
|
async function run(args, target) {
|
|
102
102
|
var queueAction = args.action.indexOf("queue_") === 0;
|
|
@@ -105,12 +105,13 @@ function attachClayProductControl(ctx) {
|
|
|
105
105
|
if (args.action !== "stop") {
|
|
106
106
|
var question = input.description(args) + "\nProject: " + target.slug + "\nSession: " + (live.session.title || "New Session") + " (" + target.ref + ")\nWhy: " + args.why;
|
|
107
107
|
if (queueAction) question += "\nQueue revision: " + target.queueRevision + ". Queue changes never resume a stopped Driver.";
|
|
108
|
+
if (args.action === "force_stop") question += "\nForce stop scope: this Driver and " + (target.scope.length - 1) + " owned Workers. Panes and conversation history remain. Every target must support YOKE force termination before any effect. Query runtime exit is reported separately from unconfirmed external commands or requests.";
|
|
108
109
|
if (args.action === "delete") question += "\nPermanently deletes " + target.scope.length + " sessions: this Driver and all its owned Worker history. Inspect again if anything changes.";
|
|
109
110
|
var result = await review(question, function () { authorize(); check(target, queueAction); });
|
|
110
111
|
if (result.status !== "submitted" || !result.answers.control || result.answers.control.length !== 1 || result.answers.control[0] !== "Apply exact change") return { status: "cancelled", action: args.action };
|
|
111
112
|
live = check(target, queueAction);
|
|
112
113
|
}
|
|
113
|
-
if (!queueAction && live.session.taskStopRequested && args.action !== "stop" && args.action !== "rename" && args.action !== "delete") throw new Error("The target was stopped. Resume it explicitly through its Driver.");
|
|
114
|
+
if (!queueAction && live.session.taskStopRequested && args.action !== "stop" && args.action !== "force_stop" && args.action !== "rename" && args.action !== "delete") throw new Error("The target was stopped. Resume it explicitly through its Driver.");
|
|
114
115
|
if (typeof live.project.applyProductSessionAction !== "function") throw new Error("Target project controls are unavailable.");
|
|
115
116
|
if (busy.has(live.session)) throw new Error("Another control action is applying to this target. Inspect again after it settles.");
|
|
116
117
|
if (args.action === "stop" && display.current()) display.current().start("running");
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Persisted Clay-level access rules for parent projects and exact worktrees.
|
|
2
2
|
// Filesystem and Git permissions are deliberately outside this module.
|
|
3
|
+
var worktreePreferences = require("./worktree-preferences");
|
|
3
4
|
|
|
4
5
|
function parentSlugFor(slug) {
|
|
5
6
|
if (typeof slug !== "string") return null;
|
|
@@ -33,7 +34,7 @@ function pendingLifecycleOperation(config, project) {
|
|
|
33
34
|
});
|
|
34
35
|
}
|
|
35
36
|
|
|
36
|
-
function getProjectAccess(config, slug, osUsers) {
|
|
37
|
+
function getProjectAccess(config, slug, osUsers, worktreeIdentity) {
|
|
37
38
|
var parentSlug = parentSlugFor(slug);
|
|
38
39
|
var project = findProject(config, parentSlug || slug);
|
|
39
40
|
if (!project) return { error: "Project not found" };
|
|
@@ -60,9 +61,16 @@ function getProjectAccess(config, slug, osUsers) {
|
|
|
60
61
|
|
|
61
62
|
var grants = project.worktreeAllowedUsers && project.worktreeAllowedUsers[slug];
|
|
62
63
|
var worktreeAllowedUsers = uniqueUsers(grants);
|
|
64
|
+
var preference = worktreePreferences.entry(config, parentSlug, slug);
|
|
65
|
+
var identityKnown = worktreeIdentity && worktreeIdentity.slug === slug &&
|
|
66
|
+
typeof worktreeIdentity.path === "string" && worktreeIdentity.path &&
|
|
67
|
+
typeof worktreeIdentity.changeSetId === "string" && worktreeIdentity.changeSetId;
|
|
68
|
+
var boundPreference = identityKnown
|
|
69
|
+
? worktreePreferences.blocked(config, parentSlug, slug, worktreeIdentity.path, worktreeIdentity.changeSetId)
|
|
70
|
+
: preference && preference.lifecycleState;
|
|
63
71
|
return {
|
|
64
72
|
slug: slug,
|
|
65
|
-
lifecycleExecutionPaused: project.lifecycleExecutionPaused === true || !!pendingLifecycleOperation(config, project),
|
|
73
|
+
lifecycleExecutionPaused: project.lifecycleExecutionPaused === true || !!pendingLifecycleOperation(config, project) || !!boundPreference,
|
|
66
74
|
ownershipRecovery: project.ownershipRecovery || null,
|
|
67
75
|
visibility: visibility,
|
|
68
76
|
allowedUsers: uniqueUsers(parentAllowedUsers.concat(worktreeAllowedUsers)),
|
package/lib/daemon-projects.js
CHANGED
|
@@ -9,6 +9,7 @@ var { scanWorktreesResult, isWorktree } = require("./worktree");
|
|
|
9
9
|
var { daemonSync } = require("./daemon-sync");
|
|
10
10
|
var logContext = require("./project-log-context");
|
|
11
11
|
var daemonProjectAccess = require("./daemon-project-access");
|
|
12
|
+
var worktreePreferences = require("./worktree-preferences");
|
|
12
13
|
var saveConfig = require("./config").saveConfig;
|
|
13
14
|
|
|
14
15
|
// --- Worktree tracking state ---
|
|
@@ -31,7 +32,9 @@ function isWorktreeSlug(slug) {
|
|
|
31
32
|
* @param {string} parentIcon - icon of parent project
|
|
32
33
|
* @param {string} parentOwnerId - owner user ID
|
|
33
34
|
*/
|
|
34
|
-
function scanAndRegisterWorktrees(relay, parentPath, parentSlug, parentIcon, parentOwnerId, projectKnowledgeId, osUserInfo, config) {
|
|
35
|
+
function scanAndRegisterWorktrees(relay, parentPath, parentSlug, parentIcon, parentOwnerId, projectKnowledgeId, osUserInfo, config, getConfig, getOsUserInfo) {
|
|
36
|
+
var savedParent = config && (config.projects || []).find(function (item) { return item.slug === parentSlug; });
|
|
37
|
+
if (savedParent && (savedParent.lifecycleState || worktreePreferences.pendingRemoval(config, parentSlug))) return;
|
|
35
38
|
if (isWorktree(parentPath, osUserInfo)) return;
|
|
36
39
|
var scan = scanWorktreesResult(parentPath, osUserInfo);
|
|
37
40
|
if (!scan.ok) return;
|
|
@@ -50,12 +53,18 @@ function scanAndRegisterWorktrees(relay, parentPath, parentSlug, parentIcon, par
|
|
|
50
53
|
if (alreadyRegistered) continue;
|
|
51
54
|
var context = logContext.resolveWorktreeContext(wt.path, projectKnowledgeId, { branch: wt.branch || wt.dirName }, osUserInfo);
|
|
52
55
|
var wtMeta = Object.assign({ parentSlug: parentSlug, external: wt.external, projectKnowledgeId: projectKnowledgeId }, context);
|
|
53
|
-
|
|
56
|
+
if (worktreePreferences.blocked(config || {}, parentSlug, wtSlug, wt.path, context.changeSetId)) continue;
|
|
57
|
+
relay.addProject(wt.path, wtSlug, wt.branch || wt.dirName, worktreePreferences.icon(config || {}, parentSlug, wtSlug, wt.path, context.changeSetId), parentOwnerId, wtMeta);
|
|
54
58
|
worktreeRegistry[parentSlug].push(wtSlug);
|
|
55
59
|
console.log("[daemon] Registered worktree:", wtSlug, "->", wt.path, wt.external ? "(external)" : "(inside project folder)");
|
|
56
60
|
}
|
|
57
61
|
daemonSync.register(getWorktreeSyncKey(parentSlug), function () {
|
|
58
|
-
|
|
62
|
+
var currentConfig = getConfig ? getConfig() : config;
|
|
63
|
+
var current = currentConfig && (currentConfig.projects || []).find(function (item) { return item.slug === parentSlug; });
|
|
64
|
+
if (currentConfig && !current) return;
|
|
65
|
+
rescanWorktrees(relay, current ? current.path : parentPath, parentSlug, current ? current.icon : parentIcon,
|
|
66
|
+
current ? current.ownerId : parentOwnerId, currentConfig, current ? current.projectKnowledgeId : projectKnowledgeId,
|
|
67
|
+
current && getOsUserInfo ? getOsUserInfo(current) : osUserInfo);
|
|
59
68
|
});
|
|
60
69
|
}
|
|
61
70
|
|
|
@@ -69,6 +78,8 @@ function scanAndRegisterWorktrees(relay, parentPath, parentSlug, parentIcon, par
|
|
|
69
78
|
* @param {object} [config] - daemon config (optional, for broadcasting project count)
|
|
70
79
|
*/
|
|
71
80
|
function rescanWorktrees(relay, parentPath, parentSlug, parentIcon, parentOwnerId, config, projectKnowledgeId, osUserInfo) {
|
|
81
|
+
var liveParent = config && (config.projects || []).find(function (item) { return item.slug === parentSlug; });
|
|
82
|
+
if (liveParent && (liveParent.lifecycleState || worktreePreferences.pendingRemoval(config, parentSlug))) return;
|
|
72
83
|
if (worktreeScanning[parentSlug]) return;
|
|
73
84
|
worktreeScanning[parentSlug] = true;
|
|
74
85
|
try {
|
|
@@ -96,7 +107,8 @@ function rescanWorktrees(relay, parentPath, parentSlug, parentIcon, parentOwnerI
|
|
|
96
107
|
if (!found) {
|
|
97
108
|
var context = logContext.resolveWorktreeContext(wt.path, projectKnowledgeId, { branch: wt.branch || wt.dirName }, osUserInfo);
|
|
98
109
|
var wtMeta = Object.assign({ parentSlug: parentSlug, external: wt.external, projectKnowledgeId: projectKnowledgeId }, context);
|
|
99
|
-
|
|
110
|
+
if (worktreePreferences.blocked(config || {}, parentSlug, wtSlug, wt.path, context.changeSetId)) continue;
|
|
111
|
+
relay.addProject(wt.path, wtSlug, wt.branch || wt.dirName, worktreePreferences.icon(config || {}, parentSlug, wtSlug, wt.path, context.changeSetId), parentOwnerId, wtMeta);
|
|
100
112
|
if (!worktreeRegistry[parentSlug]) worktreeRegistry[parentSlug] = [];
|
|
101
113
|
worktreeRegistry[parentSlug].push(wtSlug);
|
|
102
114
|
console.log("[daemon] Rescan: added worktree:", wtSlug);
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Shared authoritative worktree display/removal service. Reviews expire after five minutes.
|
|
2
|
+
var fs = require('fs');
|
|
3
|
+
var path = require('path');
|
|
4
|
+
var crypto = require('crypto');
|
|
5
|
+
var access = require('./daemon-project-access');
|
|
6
|
+
var prefs = require('./worktree-preferences');
|
|
7
|
+
var identities = require('./worktree-identity');
|
|
8
|
+
var branches = require('./worktree-branch');
|
|
9
|
+
var operationsModule = require('./project-lifecycle-operations');
|
|
10
|
+
function attachWorktreeControls(ctx) {
|
|
11
|
+
var locks = new Set(), reviews = new Map();
|
|
12
|
+
function config() { return ctx.getConfig(); }
|
|
13
|
+
function relay() { return ctx.getRelay(); }
|
|
14
|
+
function copy() { return JSON.parse(JSON.stringify(config())); }
|
|
15
|
+
function save(next) { ctx.saveConfig(next); ctx.setConfig(next); }
|
|
16
|
+
function parent(slug) { return (config().projects || []).find(function (item) { return item.slug === access.parentSlugFor(slug); }); }
|
|
17
|
+
function runtime(slug) { var found; relay().forEachProject(function (item, key) { if (key === slug) found = item; }); return found; }
|
|
18
|
+
function publish() { relay().broadcastAll({ type: 'projects_updated', projects: relay().getProjects(), projectCount: config().projects.length }); }
|
|
19
|
+
// Context-bound identities come from the server, including contexts retained during shutdown.
|
|
20
|
+
function getAccess(slug, runtimeIdentity) {
|
|
21
|
+
var live = runtimeIdentity ? null : runtime(slug);
|
|
22
|
+
return access.getProjectAccess(config(), slug, config().osUsers, runtimeIdentity || live && live.getStatus());
|
|
23
|
+
}
|
|
24
|
+
function authorize(slug, userId, removal) {
|
|
25
|
+
var project = parent(slug) || (config().projects || []).find(function (item) { return item.slug === slug; });
|
|
26
|
+
if (!project || project.lifecycleState || project.ownershipRecovery || project.lifecycleExecutionPaused || access.pendingLifecycleOperation(config(), project)) throw new Error('Project registration is unavailable or paused.');
|
|
27
|
+
var user = userId && ctx.findUser(userId);
|
|
28
|
+
if (ctx.users.isMultiUser()) {
|
|
29
|
+
if (!user || !ctx.users.getEffectivePermissions(user, config().osUsers)[removal ? 'deleteProject' : 'projectSettings']) throw new Error(access.parentSlugFor(slug) ? 'Only the project owner with the required permission may change this worktree.' : 'The required project permission is unavailable.');
|
|
30
|
+
if (access.parentSlugFor(slug)) {
|
|
31
|
+
if (user.id !== project.ownerId) throw new Error('Only the project owner with the required permission may change this worktree.');
|
|
32
|
+
} else if (!ctx.users.canAccessProject(user.id, getAccess(slug))) throw new Error('Project access is not permitted.');
|
|
33
|
+
} else if (access.parentSlugFor(slug) && project.ownerId && project.ownerId !== userId) throw new Error('Project owner does not match.');
|
|
34
|
+
return project;
|
|
35
|
+
}
|
|
36
|
+
function record(next, slug, status) {
|
|
37
|
+
var project = next.projects.find(function (item) { return item.slug === access.parentSlugFor(slug); });
|
|
38
|
+
if (!project.worktreePreferences) project.worktreePreferences = {};
|
|
39
|
+
var old = project.worktreePreferences[slug];
|
|
40
|
+
if (!prefs.matches(old, status.path, status.changeSetId)) old = {};
|
|
41
|
+
project.worktreePreferences[slug] = Object.assign({}, old, { slug: slug, path: status.path, changeSetId: status.changeSetId, projectKnowledgeId: status.projectKnowledgeId, ownerId: project.ownerId || null });
|
|
42
|
+
return project.worktreePreferences[slug];
|
|
43
|
+
}
|
|
44
|
+
function setIcon(slug, icon, userId) {
|
|
45
|
+
var persisted = false;
|
|
46
|
+
try {
|
|
47
|
+
var project = authorize(slug, userId, false);
|
|
48
|
+
if (locks.has(project.slug) || prefs.pendingRemoval(config(), project.slug)) throw new Error('Worktree removal is in progress.');
|
|
49
|
+
if (icon !== null && (typeof icon !== 'string' || icon.length > 64 || /[\u0000-\u001f\u007f]/.test(icon))) throw new Error('Invalid project icon.');
|
|
50
|
+
var live = runtime(slug); if (!live) throw new Error('Project runtime is unavailable.');
|
|
51
|
+
var next = copy();
|
|
52
|
+
if (access.parentSlugFor(slug)) {
|
|
53
|
+
var status = live.getStatus(), git = identities.inspect(project.path, status.path, ctx.osIdentity(project), false);
|
|
54
|
+
verifyMarker(git, status);
|
|
55
|
+
var old = prefs.entry(config(), project.slug, slug);
|
|
56
|
+
if (prefs.matches(old, status.path, status.changeSetId) && old.lifecycleState) throw new Error('Worktree removal is pending.');
|
|
57
|
+
record(next, slug, status).icon = icon;
|
|
58
|
+
}
|
|
59
|
+
else next.projects.find(function (item) { return item.slug === slug; }).icon = icon;
|
|
60
|
+
save(next); persisted = true;
|
|
61
|
+
if (!access.parentSlugFor(slug) && ctx.syncRecent) { try { ctx.syncRecent(next.projects); } catch (error) {} }
|
|
62
|
+
if (relay().setProjectIcon(slug, icon) !== true) throw new Error('The icon was saved, but runtime publication was not acknowledged.');
|
|
63
|
+
publish();
|
|
64
|
+
return { ok: true, persisted: true };
|
|
65
|
+
} catch (error) { return { ok: false, persisted: persisted, error: error.message }; }
|
|
66
|
+
}
|
|
67
|
+
function verifyMarker(git, status) {
|
|
68
|
+
var marker = JSON.parse(fs.readFileSync(path.join(git.git.path, 'clay-change-set.json'), 'utf8'));
|
|
69
|
+
if (marker.changeSetId !== status.changeSetId || marker.projectKnowledgeId !== status.projectKnowledgeId) throw new Error('The worktree change-set identity changed.');
|
|
70
|
+
}
|
|
71
|
+
function binding(slug, userId, action, status) {
|
|
72
|
+
var project = authorize(slug, userId, true), os = ctx.osIdentity(project);
|
|
73
|
+
if (!access.parentSlugFor(slug) || project.slug.indexOf('mate-') === 0) throw new Error('Choose an ordinary registered worktree.');
|
|
74
|
+
if (locks.has(project.slug)) throw new Error('Another worktree operation is in progress.');
|
|
75
|
+
if (!status || !status.isWorktree || status.slug !== slug || !status.changeSetId) throw new Error('Exact worktree registration is unavailable.');
|
|
76
|
+
var expected = project.slug + '--' + path.basename(status.path);
|
|
77
|
+
if (slug !== expected) throw new Error('Worktree registration does not match its directory.');
|
|
78
|
+
(config().projects || []).forEach(function (other) {
|
|
79
|
+
if (other.slug === project.slug) return;
|
|
80
|
+
var root; try { root = fs.realpathSync(other.path); } catch (error) { root = path.resolve(other.path); }
|
|
81
|
+
if (root === status.path || require('./worktree').isPathInside(status.path, root) || require('./worktree').isPathInside(root, status.path)) throw new Error('Worktree directory intersects another registered project.');
|
|
82
|
+
});
|
|
83
|
+
relay().forEachProject(function (other, otherSlug) {
|
|
84
|
+
if (otherSlug === slug || otherSlug === project.slug) return;
|
|
85
|
+
var root = other.getStatus().path;
|
|
86
|
+
if (root === status.path || require('./worktree').isPathInside(status.path, root)) throw new Error('Another registered runtime is inside this worktree.');
|
|
87
|
+
});
|
|
88
|
+
var git = identities.inspect(project.path, status.path, os, action === 'delete');
|
|
89
|
+
verifyMarker(git, status);
|
|
90
|
+
if (config().osUsers && (!os || git.parent.uid !== os.uid || git.target.uid !== os.uid)) throw new Error('Mapped OS ownership does not match the worktree.');
|
|
91
|
+
var base = Object.assign({}, project); delete base.worktreePreferences;
|
|
92
|
+
return { parent: base, status: { slug: slug, path: status.path, isWorktree: true, changeSetId: status.changeSetId, projectKnowledgeId: status.projectKnowledgeId }, os: os, git: git };
|
|
93
|
+
}
|
|
94
|
+
function preview(slug, action, userId, connection, deleteBranch) {
|
|
95
|
+
if (deleteBranch !== undefined && typeof deleteBranch !== 'boolean') throw new Error('Invalid local branch choice.');
|
|
96
|
+
if (deleteBranch && action !== 'delete') throw new Error('Exclude always keeps the local branch.');
|
|
97
|
+
if (action !== 'delete' && action !== 'exclude') throw new Error('Choose Exclude or Delete worktree.');
|
|
98
|
+
reviews.forEach(function (item, key) { if (item.expires < Date.now()) reviews.delete(key); });
|
|
99
|
+
if (reviews.size >= 128) throw new Error('Too many pending worktree reviews.');
|
|
100
|
+
var live = runtime(slug), status = live && live.getStatus();
|
|
101
|
+
var bound = binding(slug, userId, action, status);
|
|
102
|
+
var existing = prefs.entry(config(), bound.parent.slug, slug);
|
|
103
|
+
if (prefs.matches(existing, status.path, status.changeSetId) && existing.lifecycleState) throw new Error('Worktree removal is pending. Inspect operation ' + existing.lifecycleOperationId + ' before recovery.');
|
|
104
|
+
var branch = deleteBranch ? branches.inspect(bound.git, bound.os, false) : null;
|
|
105
|
+
var token = crypto.randomUUID();
|
|
106
|
+
reviews.set(token, { slug: slug, action: action, userId: userId, connection: connection, bound: bound, branch: branch, deleteBranch: deleteBranch === true, live: live, expires: Date.now() + 300000 });
|
|
107
|
+
return { ok: true, token: token, slug: slug, action: action, path: bound.git.target.path, branch: bound.git.branch, deleteBranch: deleteBranch === true, localBranch: /^refs\/heads\//.test(bound.git.branch), expiresInSeconds: 300 };
|
|
108
|
+
}
|
|
109
|
+
function find(slug) { var project = parent(slug); return project && prefs.entry(config(), project.slug, slug); }
|
|
110
|
+
var operations = operationsModule.attachLifecycleOperations({ config: config, find: find, directory: function (value) { return identities.identity(value, true); }, storageDir: ctx.storageDir,
|
|
111
|
+
record: function () {}, snapshot: copy, save: save, relay: relay, publish: publish });
|
|
112
|
+
async function execute(token, userId, connection, validate, deleteBranch) {
|
|
113
|
+
if (validate) validate();
|
|
114
|
+
var review = reviews.get(token); reviews.delete(token);
|
|
115
|
+
if (!review || review.expires < Date.now() || review.userId !== userId || review.connection !== connection) throw new Error('Worktree confirmation expired or belongs to another connection. Review again.');
|
|
116
|
+
if (deleteBranch !== undefined && deleteBranch !== review.deleteBranch) throw new Error('The local branch choice does not match the reviewed confirmation.');
|
|
117
|
+
var slug = review.slug, bound = binding(slug, userId, review.action, review.bound.status);
|
|
118
|
+
if (JSON.stringify(bound) !== JSON.stringify(review.bound) || runtime(slug) !== review.live) throw new Error('The reviewed worktree changed. Review it again.');
|
|
119
|
+
if (review.branch) branches.exact(bound.git, review.branch, bound.os, false);
|
|
120
|
+
var parentSlug = bound.parent.slug, op, reserved = false, diskRemoved = false, branchRemoved = false;
|
|
121
|
+
var contextState = require('./project-log-context').isMerged(bound.parent.path, bound.status.path, bound.os) ? 'merged' : 'archived';
|
|
122
|
+
var archive = relay().captureWorktreeLogContext ? relay().captureWorktreeLogContext(slug) : null;
|
|
123
|
+
locks.add(parentSlug);
|
|
124
|
+
try {
|
|
125
|
+
var next = copy(), registration = record(next, slug, review.bound.status);
|
|
126
|
+
var targets = relay().getProjectLifecycleTargets(slug);
|
|
127
|
+
if (targets.length !== 1 || targets[0].slug !== slug || targets[0].path !== bound.status.path) throw new Error('Exact runtime closure scope is unavailable.');
|
|
128
|
+
var result = { ok: true, status: 'completed', slug: slug, action: review.action, filesDeleted: review.action === 'delete', branchDeleted: false, deleteBranch: review.deleteBranch, branch: bound.git.branch };
|
|
129
|
+
op = operations.begin(next, registration, 'remove', result, null, targets);
|
|
130
|
+
op.worktreeReview = { git: bound.git, branch: review.branch, deleteBranch: review.deleteBranch };
|
|
131
|
+
save(next); reserved = true;
|
|
132
|
+
await relay().closeProjectLifecycle(targets, false);
|
|
133
|
+
if (relay().getProjectLifecycleTargets(slug).length) throw new Error('A runtime appeared during worktree shutdown.');
|
|
134
|
+
operations.confirmClosed(op); operations.exact(op);
|
|
135
|
+
if (validate) validate();
|
|
136
|
+
locks.delete(parentSlug);
|
|
137
|
+
var current = binding(slug, userId, review.action, bound.status);
|
|
138
|
+
locks.add(parentSlug);
|
|
139
|
+
if (JSON.stringify(current) !== JSON.stringify(bound)) throw new Error('Worktree identity or authorization changed during shutdown.');
|
|
140
|
+
if (review.branch) branches.exact(bound.git, review.branch, bound.os, false);
|
|
141
|
+
if (review.action === 'delete') { identities.remove(bound.git, bound.os); diskRemoved = true; }
|
|
142
|
+
if (review.branch) { await branches.remove(bound.git, review.branch, bound.os, function () {
|
|
143
|
+
if (validate) validate();
|
|
144
|
+
if (relay().getProjectLifecycleTargets(slug).length) throw new Error('A runtime appeared during local branch deletion.');
|
|
145
|
+
var project = authorize(slug, userId, true), base = Object.assign({}, project); delete base.worktreePreferences;
|
|
146
|
+
if (JSON.stringify(base) !== JSON.stringify(bound.parent) || JSON.stringify(ctx.osIdentity(project)) !== JSON.stringify(bound.os)) throw new Error('Worktree authorization or mapped OS identity changed during branch deletion.');
|
|
147
|
+
if (JSON.stringify(find(slug)) !== op.expected) throw new Error('The exact saved worktree registration changed.');
|
|
148
|
+
}); branchRemoved = true; result.branchDeleted = true; }
|
|
149
|
+
if (archive) archive(contextState);
|
|
150
|
+
// Record the observed disk effect even if subsequent persistence/publication fails.
|
|
151
|
+
next = copy(); var saved = next.projects.find(function (item) { return item.slug === parentSlug; }).worktreePreferences[slug];
|
|
152
|
+
delete saved.lifecycleState; delete saved.lifecycleOperationId; saved.excluded = true;
|
|
153
|
+
access.clearWorktreeGrant(next, slug);
|
|
154
|
+
operations.complete(next, op, saved); save(next);
|
|
155
|
+
ctx.unregister(parentSlug, slug);
|
|
156
|
+
publish();
|
|
157
|
+
return Object.assign(result, { operationId: op.id, runtimeClosed: true });
|
|
158
|
+
} catch (error) {
|
|
159
|
+
if (reserved) error.lifecycleResult = Object.assign(operations.partial(op, error.message), { filesDeleted: diskRemoved, branchDeleted: branchRemoved, deleteBranch: review.deleteBranch, branch: bound.git.branch, recoveryAction: 'inspect_worktree_removal', directoryAbsent: !fs.existsSync(bound.git.target.path), runtimeClosureConfirmed: operations.closed(op) });
|
|
160
|
+
if (reserved) {
|
|
161
|
+
try { var partialConfig = copy(); partialConfig.projectLifecycleOperations[op.id].observed = error.lifecycleResult; save(partialConfig); } catch (saveError) {}
|
|
162
|
+
}
|
|
163
|
+
throw error;
|
|
164
|
+
} finally { locks.delete(parentSlug); }
|
|
165
|
+
}
|
|
166
|
+
function busy(parentSlug) {
|
|
167
|
+
return locks.has(parentSlug) || prefs.pendingRemoval(config(), parentSlug);
|
|
168
|
+
}
|
|
169
|
+
return { getAccess: getAccess, setIcon: setIcon, preview: preview, execute: execute, busy: busy };
|
|
170
|
+
}
|
|
171
|
+
module.exports = { attachWorktreeControls: attachWorktreeControls };
|
package/lib/daemon.js
CHANGED
|
@@ -32,7 +32,7 @@ var osUsersMod = require("./os-users");
|
|
|
32
32
|
var { scheduleOsUserDiagnosticsAsync } = require("./os-user-diagnostics");
|
|
33
33
|
var { checkAclSupport, grantProjectAccess, revokeProjectAccess, provisionAllUsers, provisionLinuxUser, grantAllUsersAccess, deactivateLinuxUser, ensureProjectsDir } = osUsersMod;
|
|
34
34
|
var usersModule = require("./users");
|
|
35
|
-
var { createWorktree,
|
|
35
|
+
var { createWorktree, isWorktree } = require("./worktree");
|
|
36
36
|
var mates = require("./mates");
|
|
37
37
|
var mateKnowledgeMigration = require("./mate-knowledge-migration");
|
|
38
38
|
var { isWorktreeSlug, scanAndRegisterWorktrees, rescanWorktrees, cleanupWorktreesForParent, getFilteredRemovedProjects, registerWorktreeSlug, unregisterWorktreeSlug } = require("./daemon-projects");
|
|
@@ -183,7 +183,7 @@ function worktreeOsUserInfo(project, requestUser) {
|
|
|
183
183
|
function registerProjectWorktrees(project, requestUser) {
|
|
184
184
|
try {
|
|
185
185
|
var osUserInfo = worktreeOsUserInfo(project, requestUser);
|
|
186
|
-
scanAndRegisterWorktrees(relay, project.path, project.slug, project.icon, project.ownerId, project.projectKnowledgeId, osUserInfo, config);
|
|
186
|
+
scanAndRegisterWorktrees(relay, project.path, project.slug, project.icon, project.ownerId, project.projectKnowledgeId, osUserInfo, config, function () { return config; }, worktreeOsUserInfo);
|
|
187
187
|
} catch (e) {
|
|
188
188
|
console.error("[daemon] Failed to scan worktrees for " + project.slug + ": " + e.message);
|
|
189
189
|
}
|
|
@@ -195,7 +195,13 @@ var projectLifecycle = require("./daemon-project-lifecycle").attachProjectLifecy
|
|
|
195
195
|
findUser: usersModule.findUserById, registerWorktrees: registerProjectWorktrees,
|
|
196
196
|
});
|
|
197
197
|
var lifecycleCallbacks = require("./daemon-project-lifecycle-callbacks").attachLifecycleCallbacks({ service: projectLifecycle });
|
|
198
|
+
var worktreeControls = require("./daemon-worktree-controls").attachWorktreeControls({
|
|
199
|
+
getConfig: function () { return config; }, setConfig: function (next) { config = next; }, saveConfig: saveConfig,
|
|
200
|
+
getRelay: function () { return relay; }, findUser: usersModule.findUserById, users: usersModule,
|
|
201
|
+
osIdentity: worktreeOsUserInfo, syncRecent: syncClayrc, storageDir: require("./config").CONFIG_DIR, unregister: unregisterWorktreeSlug,
|
|
202
|
+
});
|
|
198
203
|
var relay = createServer({
|
|
204
|
+
worktreeControls: worktreeControls,
|
|
199
205
|
projectLifecycle: projectLifecycle,
|
|
200
206
|
tlsOptions: tlsOptions,
|
|
201
207
|
caPath: caRoot,
|
|
@@ -316,43 +322,7 @@ var relay = createServer({
|
|
|
316
322
|
});
|
|
317
323
|
},
|
|
318
324
|
onRemoveProject: function (slug, userId) {
|
|
319
|
-
|
|
320
|
-
if (isWorktreeSlug(slug)) {
|
|
321
|
-
var wtParent = slug.split("--")[0];
|
|
322
|
-
var wtDirName = slug.split("--").slice(1).join("--");
|
|
323
|
-
// Find parent project path
|
|
324
|
-
var parentProject = null;
|
|
325
|
-
for (var pi = 0; pi < config.projects.length; pi++) {
|
|
326
|
-
if (config.projects[pi].slug === wtParent) { parentProject = config.projects[pi]; break; }
|
|
327
|
-
}
|
|
328
|
-
if (parentProject) {
|
|
329
|
-
var wtPath = path.join(parentProject.path, wtDirName);
|
|
330
|
-
var removeUser = userId ? usersModule.findUserById(userId) : null;
|
|
331
|
-
var removeOsUserInfo;
|
|
332
|
-
try {
|
|
333
|
-
removeOsUserInfo = worktreeOsUserInfo(parentProject, removeUser);
|
|
334
|
-
} catch (e) {
|
|
335
|
-
return { ok: false, error: e.message };
|
|
336
|
-
}
|
|
337
|
-
var contextStatus = projectLogContext.isMerged(parentProject.path, wtPath, removeOsUserInfo) ? "merged" : "archived";
|
|
338
|
-
var rmResult = removeWorktree(parentProject.path, wtDirName, removeOsUserInfo);
|
|
339
|
-
if (!rmResult.ok) {
|
|
340
|
-
console.log("[daemon] Failed to remove worktree:", slug, rmResult.error);
|
|
341
|
-
return { ok: false, error: rmResult.error };
|
|
342
|
-
}
|
|
343
|
-
relay.setProjectLogContextState(slug, contextStatus);
|
|
344
|
-
}
|
|
345
|
-
relay.removeProject(slug);
|
|
346
|
-
unregisterWorktreeSlug(wtParent, slug);
|
|
347
|
-
if (daemonProjectAccess.clearWorktreeGrant(config, slug)) saveConfig(config);
|
|
348
|
-
console.log("[daemon] Removed worktree (web):", slug);
|
|
349
|
-
relay.broadcastAll({
|
|
350
|
-
type: "projects_updated",
|
|
351
|
-
projects: relay.getProjects(),
|
|
352
|
-
projectCount: config.projects.length,
|
|
353
|
-
});
|
|
354
|
-
return { ok: true };
|
|
355
|
-
}
|
|
325
|
+
if (isWorktreeSlug(slug)) return { ok: false, error: "Use the reviewed Exclude or Delete worktree control." };
|
|
356
326
|
return lifecycleCallbacks.onRemoveProject(slug, userId);
|
|
357
327
|
},
|
|
358
328
|
onReorderProjects: function (slugs) {
|
|
@@ -390,26 +360,8 @@ var relay = createServer({
|
|
|
390
360
|
if (isWorktreeSlug(slug)) { relay.setProjectTitle(slug, newTitle); return { ok: true }; }
|
|
391
361
|
return lifecycleCallbacks.onSetProjectTitle(slug, newTitle);
|
|
392
362
|
},
|
|
393
|
-
onSetProjectIcon: function (slug, newIcon) {
|
|
394
|
-
|
|
395
|
-
for (var ii = 0; ii < config.projects.length; ii++) {
|
|
396
|
-
if (config.projects[ii].slug === slug) {
|
|
397
|
-
if (newIcon) {
|
|
398
|
-
config.projects[ii].icon = newIcon;
|
|
399
|
-
} else {
|
|
400
|
-
delete config.projects[ii].icon;
|
|
401
|
-
}
|
|
402
|
-
break;
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
saveConfig(config);
|
|
406
|
-
try { syncClayrc(config.projects); } catch (e) {}
|
|
407
|
-
relay.broadcastAll({
|
|
408
|
-
type: "projects_updated",
|
|
409
|
-
projects: relay.getProjects(),
|
|
410
|
-
projectCount: config.projects.length,
|
|
411
|
-
});
|
|
412
|
-
return { ok: true };
|
|
363
|
+
onSetProjectIcon: function (slug, newIcon, userId) {
|
|
364
|
+
return worktreeControls.setIcon(slug, newIcon, userId);
|
|
413
365
|
},
|
|
414
366
|
onSetProjectSessionVisibilityDefault: function (slug, visibility) {
|
|
415
367
|
return projectLifecycle.update(slug, "session_visibility", visibility);
|
|
@@ -865,9 +817,7 @@ var relay = createServer({
|
|
|
865
817
|
}
|
|
866
818
|
return { error: "Project not found" };
|
|
867
819
|
},
|
|
868
|
-
onGetProjectAccess:
|
|
869
|
-
return daemonProjectAccess.getProjectAccess(config, slug, config.osUsers);
|
|
870
|
-
},
|
|
820
|
+
onGetProjectAccess: worktreeControls.getAccess,
|
|
871
821
|
onUserProvisioned: function (userId, linuxUser) {
|
|
872
822
|
// Grant ACL on all public projects to the newly provisioned user
|
|
873
823
|
if (!config.osUsers || !linuxUser) return;
|
|
@@ -891,6 +841,7 @@ var relay = createServer({
|
|
|
891
841
|
if (config.projects[j].slug === parentSlug) { parent = config.projects[j]; break; }
|
|
892
842
|
}
|
|
893
843
|
if (!parent) return { ok: false, error: "Parent project not found" };
|
|
844
|
+
if (worktreeControls.busy(parentSlug) || parent.lifecycleState) return { ok: false, error: "Worktree lifecycle operation is pending." };
|
|
894
845
|
var osUserInfo;
|
|
895
846
|
try {
|
|
896
847
|
osUserInfo = worktreeOsUserInfo(parent, requestUser);
|
|
@@ -909,7 +860,7 @@ var relay = createServer({
|
|
|
909
860
|
baseCommit: baseCommit,
|
|
910
861
|
}, osUserInfo);
|
|
911
862
|
var wtMeta = Object.assign({ parentSlug: parentSlug, external: false, projectKnowledgeId: parent.projectKnowledgeId }, context);
|
|
912
|
-
relay.addProject(result.path, wtSlug, branchName,
|
|
863
|
+
relay.addProject(result.path, wtSlug, branchName, null, parent.ownerId, wtMeta);
|
|
913
864
|
registerWorktreeSlug(parentSlug, wtSlug);
|
|
914
865
|
console.log("[daemon] Created worktree:", wtSlug, "->", result.path);
|
|
915
866
|
relay.broadcastAll({
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Exact human-settings review, one-use receipts, and scoped installer lifecycle.
|
|
2
|
+
var crypto = require('crypto');
|
|
3
|
+
var locks = new Map();
|
|
4
|
+
function attach(ctx, personal) {
|
|
5
|
+
var pending = new WeakMap(), running = new Map();
|
|
6
|
+
function emit(ws, request, state, extra) {
|
|
7
|
+
if (ws.readyState === 1) ctx.sendTo(ws, Object.assign({type:'personal_provider_install',vendor:request.vendor,personalRequestId:request.personalRequestId,state:state,requestId:request.requestId},extra || {}));
|
|
8
|
+
}
|
|
9
|
+
function valid(item) { return item.authority.validate() && Date.now() < item.expires; }
|
|
10
|
+
function release(ws) {
|
|
11
|
+
pending.delete(ws);
|
|
12
|
+
var item = running.get(ws); if (item) stop(ws,item);
|
|
13
|
+
}
|
|
14
|
+
async function stop(ws,item) {
|
|
15
|
+
if (item.stopping) return;
|
|
16
|
+
item.stopping = true; clearInterval(item.timer);
|
|
17
|
+
emit(ws,item.request,'stopping',{message:'Stopping the installation terminal…'});
|
|
18
|
+
try {
|
|
19
|
+
await ctx.tm.closeAndWait(item.terminal.id,{identity:item.terminal.identity,revalidate:function () { if (running.get(ws) !== item) throw new Error('Installation terminal changed.'); }});
|
|
20
|
+
emit(ws,item.request,'stopped',{message:'Installation terminal exited. Partial files may remain. Refresh status before explicitly reviewing another attempt.'});
|
|
21
|
+
locks.delete(item.key); running.delete(ws);
|
|
22
|
+
} catch(error) {
|
|
23
|
+
// Keep the lock: an uncertain effect must never be replayed automatically.
|
|
24
|
+
emit(ws,item.request,'unknown',{message:'Terminal shutdown could not be confirmed. Ask the server operator to inspect it before trying again.'});
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function handle(ws,msg) {
|
|
28
|
+
if (typeof msg.type !== 'string' || msg.type.indexOf('personal_provider_install_') !== 0) return false;
|
|
29
|
+
try {
|
|
30
|
+
if (msg.type === 'personal_provider_install_stop') {
|
|
31
|
+
var active = running.get(ws);
|
|
32
|
+
if (active && active.request.personalRequestId === msg.personalRequestId && active.request.vendor === msg.vendor) stop(ws,active);
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
if (msg.type === 'personal_provider_install_cancel') {
|
|
36
|
+
var review = pending.get(ws);
|
|
37
|
+
if (review && review.token === msg.reviewId && review.request.personalRequestId === msg.personalRequestId && review.request.vendor === msg.vendor) pending.delete(ws);
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
if (msg.type === 'personal_provider_install_review') {
|
|
41
|
+
if (running.has(ws)) throw new Error('An installation is already running or its result is uncertain.');
|
|
42
|
+
var authority = personal.resolveInstall(ws,msg);
|
|
43
|
+
var key = JSON.stringify([authority.runtime.uid,authority.runtime.home,msg.vendor]);
|
|
44
|
+
if (locks.has(key)) throw new Error('An installation for this runtime account is already running or needs inspection.');
|
|
45
|
+
var item = {authority:authority,key:key,request:{vendor:msg.vendor,personalRequestId:msg.personalRequestId,requestId:msg.requestId},token:crypto.randomUUID(),expires:Date.now()+120000};
|
|
46
|
+
pending.set(ws,item);
|
|
47
|
+
emit(ws,msg,'review',{reviewId:item.token,command:authority.recipe.command,source:authority.recipe.source,effects:authority.recipe.effects,runtime:authority.runtime,message:'Review this exact installation. Review expires in two minutes; leaving settings cancels it.'});
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
if (msg.type === 'personal_provider_install_start') {
|
|
51
|
+
var item = pending.get(ws); pending.delete(ws);
|
|
52
|
+
if (!item || item.token !== msg.reviewId || item.request.vendor !== msg.vendor || item.request.personalRequestId !== msg.personalRequestId || !valid(item) || locks.has(item.key)) throw new Error('Installation review expired, changed or was already used. Refresh status and review again.');
|
|
53
|
+
var fresh = personal.resolveInstall(ws,msg);
|
|
54
|
+
if (fresh.recipe !== item.authority.recipe || JSON.stringify(fresh.runtime) !== JSON.stringify(item.authority.runtime)) throw new Error('Installation target changed.');
|
|
55
|
+
item.request.requestId = msg.requestId;
|
|
56
|
+
locks.set(item.key,item); running.set(ws,item);
|
|
57
|
+
var terminal;
|
|
58
|
+
try {
|
|
59
|
+
terminal = ctx.tm.create(100,24,item.authority.runtime,ws,{kind:'provider-install',title:'Install '+msg.vendor,providerInstall:msg.vendor,
|
|
60
|
+
revalidateInstall:function () { if (!valid(item) || running.get(ws) !== item || locks.get(item.key) !== item) throw new Error('Installation permission or identity changed.'); },
|
|
61
|
+
onExit:function (session) {
|
|
62
|
+
if (item.stopping || running.get(ws) !== item) return;
|
|
63
|
+
clearInterval(item.timer); running.delete(ws); locks.delete(item.key);
|
|
64
|
+
if (!valid(item)) { emit(ws,item.request,'unknown',{message:'Installation terminal exited after account context changed. Refresh status; no task was replayed.'}); return; }
|
|
65
|
+
var installed = null;
|
|
66
|
+
try { installed = !!require('./yoke/vendor-registry').getVendorInfo(msg.vendor).resolveExecutable({user:item.authority.identity.linuxUser}); } catch(error) {}
|
|
67
|
+
if (!valid(item)) { emit(ws,item.request,'unknown',{message:'Installation discovery ended after account context changed. Refresh status before continuing.'}); return; }
|
|
68
|
+
emit(ws,item.request,session.exitCode === 0 ? 'completed' : 'failed',{installed:installed,exitCode:session.exitCode,message:session.exitCode === 0 ? (installed === null ? 'Installer exited successfully, but runtime discovery is unavailable. Refresh status to check the installation.' : installed ? 'Installer exited successfully; runtime discovery now finds this provider. Authentication remains unknown.' : 'Installer exited successfully, but runtime discovery has not found the provider. Check ~/.local/bin on the runtime PATH and refresh status.') : 'Installer did not report success. Refresh status and inspect the runtime setup before explicitly reviewing another attempt.'});
|
|
69
|
+
}});
|
|
70
|
+
if (!terminal) throw new Error('The installation terminal could not be created.');
|
|
71
|
+
item.terminal = terminal;
|
|
72
|
+
// Review expiry bounds launch, not a running install. Authority stays live.
|
|
73
|
+
item.expires = Infinity;
|
|
74
|
+
item.timer = setInterval(function () { if (!valid(item)) stop(ws,item); },250);
|
|
75
|
+
item.timer.unref();
|
|
76
|
+
emit(ws,msg,'started',{message:'Installation started in the reviewed runtime account. Waiting for terminal exit; downloads may be quiet.'});
|
|
77
|
+
} catch(error) { emit(ws,item.request,'unknown',{message:'Installation launch could not be confirmed. Ask the server operator to inspect the runtime before retrying.'}); }
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
throw new Error('Unsupported installation operation.');
|
|
81
|
+
} catch(error) { emit(ws,msg,'refused',{message:error.message}); return true; }
|
|
82
|
+
}
|
|
83
|
+
return {handle:handle,release:release,destroy:function () { pending = new WeakMap(); running.forEach(function(item,ws){stop(ws,item);}); }};
|
|
84
|
+
}
|
|
85
|
+
module.exports = {attach:attach};
|