aimux-cli 0.1.19 → 0.1.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.md +13 -4
  2. package/bin/aimux +4 -0
  3. package/bin/aimux-dev +2 -6
  4. package/dist/agent-output-parser-audit.d.ts +23 -0
  5. package/dist/agent-output-parser-audit.js +187 -0
  6. package/dist/agent-output-parser-contract.d.ts +9 -0
  7. package/dist/agent-output-parser-contract.js +33 -0
  8. package/dist/agent-output-parser-fixtures.d.ts +15 -0
  9. package/dist/agent-output-parser-fixtures.js +593 -0
  10. package/dist/agent-output-parser-harness.d.ts +21 -0
  11. package/dist/agent-output-parser-harness.js +43 -0
  12. package/dist/agent-output-parser-test-utils.d.ts +1 -0
  13. package/dist/agent-output-parser-test-utils.js +7 -0
  14. package/dist/agent-output-parser.js +215 -35
  15. package/dist/atomic-write.d.ts +15 -0
  16. package/dist/atomic-write.js +69 -4
  17. package/dist/attachment-store.d.ts +7 -0
  18. package/dist/attachment-store.js +64 -5
  19. package/dist/backend-session-discovery.d.ts +17 -0
  20. package/dist/backend-session-discovery.js +57 -0
  21. package/dist/config.js +9 -4
  22. package/dist/connection-targets.js +20 -1
  23. package/dist/context/context-bridge.js +4 -1
  24. package/dist/credentials.js +3 -6
  25. package/dist/daemon.d.ts +1 -0
  26. package/dist/daemon.js +16 -0
  27. package/dist/dashboard/index.d.ts +1 -0
  28. package/dist/dashboard/index.js +1 -0
  29. package/dist/dashboard/targets.js +14 -2
  30. package/dist/dashboard/ui-state-store.js +4 -3
  31. package/dist/last-used.js +3 -2
  32. package/dist/launcher-env.d.ts +4 -0
  33. package/dist/launcher-env.js +70 -0
  34. package/dist/main.js +16 -1
  35. package/dist/metadata-server.d.ts +13 -2
  36. package/dist/metadata-server.js +60 -4
  37. package/dist/metadata-store.js +4 -3
  38. package/dist/mobile-push-bridge.d.ts +8 -0
  39. package/dist/mobile-push-bridge.js +22 -0
  40. package/dist/mobile-push-throttle.d.ts +23 -0
  41. package/dist/mobile-push-throttle.js +53 -0
  42. package/dist/multiplexer/dashboard-model.js +3 -2
  43. package/dist/multiplexer/dashboard-ops.d.ts +3 -2
  44. package/dist/multiplexer/dashboard-ops.js +2 -2
  45. package/dist/multiplexer/dashboard-tail-methods.d.ts +3 -2
  46. package/dist/multiplexer/dashboard-tail-methods.js +2 -2
  47. package/dist/multiplexer/dashboard-view-methods.js +2 -0
  48. package/dist/multiplexer/index.d.ts +1 -1
  49. package/dist/multiplexer/index.js +4 -4
  50. package/dist/multiplexer/persistence-methods.js +2 -1
  51. package/dist/multiplexer/runtime-lifecycle-methods.js +6 -2
  52. package/dist/multiplexer/runtime-state.js +13 -1
  53. package/dist/multiplexer/service-state-snapshot.js +4 -2
  54. package/dist/multiplexer/services.js +5 -4
  55. package/dist/multiplexer/session-launch.d.ts +1 -1
  56. package/dist/multiplexer/session-launch.js +18 -6
  57. package/dist/multiplexer/session-runtime-core.js +9 -2
  58. package/dist/multiplexer/tool-picker.d.ts +2 -1
  59. package/dist/multiplexer/tool-picker.js +29 -21
  60. package/dist/notify.d.ts +1 -1
  61. package/dist/notify.js +8 -5
  62. package/dist/paths.js +50 -4
  63. package/dist/project-takeover.d.ts +1 -0
  64. package/dist/project-takeover.js +117 -0
  65. package/dist/relay-client.d.ts +10 -0
  66. package/dist/relay-client.js +5 -0
  67. package/dist/runtime-core/backend-id-reconcile.d.ts +13 -0
  68. package/dist/runtime-core/backend-id-reconcile.js +23 -0
  69. package/dist/runtime-core/exchange-store.js +3 -8
  70. package/dist/runtime-core/topology-store.js +3 -8
  71. package/dist/runtime-owner.d.ts +3 -0
  72. package/dist/runtime-owner.js +10 -0
  73. package/dist/shell-args.d.ts +13 -0
  74. package/dist/shell-args.js +25 -0
  75. package/dist/shell-hooks.d.ts +1 -0
  76. package/dist/shell-hooks.js +1 -0
  77. package/dist/team.js +4 -3
  78. package/dist/tmux/runtime-manager.js +2 -0
  79. package/dist/tui/screens/dashboard-renderers.js +6 -6
  80. package/dist/vitest.setup.d.ts +1 -0
  81. package/dist/vitest.setup.js +9 -0
  82. package/dist-ui/_expo/static/css/web-8782287775683e5a944b821b854d0f60.css +1 -0
  83. package/dist-ui/_expo/static/js/web/{entry-477c745b2adc79367a4380ecf07d9ff6.js → entry-90d00d223eefabe5cc21e4329b274fa5.js} +260 -252
  84. package/dist-ui/index.html +2 -2
  85. package/package.json +3 -1
  86. package/dist-ui/_expo/static/css/web-30453ede1678c16acb08b97e83e8646d.css +0 -1
@@ -1,6 +1,10 @@
1
+ import { homedir } from "node:os";
2
+ import { join, resolve } from "node:path";
1
3
  const PROD_WEB_APP_URL = "https://aimux.app";
2
4
  const PROD_RELAY_URL = "wss://relay.aimux.app";
3
5
  const DEV_WEB_APP_URL = "http://localhost:8081";
6
+ const DEV_HOME = join(homedir(), ".aimux-dev");
7
+ const DEV_DAEMON_PORT = "43191";
4
8
  function cleanUrl(value) {
5
9
  return value.replace(/\/$/, "");
6
10
  }
@@ -8,8 +12,23 @@ function optionalEnv(value) {
8
12
  const trimmed = value?.trim();
9
13
  return trimmed ? trimmed : undefined;
10
14
  }
15
+ function normalizeHome(value) {
16
+ const trimmed = value?.trim();
17
+ if (!trimmed)
18
+ return null;
19
+ if (trimmed === "~")
20
+ return homedir();
21
+ if (trimmed.startsWith("~/"))
22
+ return resolve(homedir(), trimmed.slice(2));
23
+ return resolve(trimmed);
24
+ }
25
+ // Mirrors launcher-env's lane detection: any one dev-lane signal marks the
26
+ // runtime as development, so the label survives a single var being unset.
11
27
  export function isDevelopmentRuntime() {
12
- return process.env.AIMUX_ENV === "development";
28
+ return (process.env.AIMUX_ENV?.trim() === "development" ||
29
+ normalizeHome(process.env.AIMUX_HOME) === DEV_HOME ||
30
+ process.env.AIMUX_DAEMON_PORT?.trim() === DEV_DAEMON_PORT ||
31
+ optionalEnv(process.env.AIMUX_WEB_APP_URL) === DEV_WEB_APP_URL);
13
32
  }
14
33
  export function resolveWebAppUrl(override) {
15
34
  const value = optionalEnv(override) ??
@@ -9,6 +9,7 @@ import { algorithmicCompact } from "./compactor.js";
9
9
  import { debugTurn, debugGit, debugContext, debugCompact } from "../debug.js";
10
10
  import { TmuxRuntimeManager } from "../tmux/runtime-manager.js";
11
11
  import { classifyToolPane } from "../tool-output-watchers.js";
12
+ import { parseAgentOutput } from "../agent-output-parser.js";
12
13
  const git = simpleGit();
13
14
  const MAX_LIVE_MD_BYTES = 50 * 1024;
14
15
  const MAX_LIVE_MD_LINES = 200;
@@ -243,7 +244,9 @@ export class ContextWatcher {
243
244
  debugContext("wrote", `${session.id}/live.md`, snapshot.length);
244
245
  const turns = readHistory(session.id, { lastN: 1 });
245
246
  if (promptVisible) {
246
- const snapshotTurn = normalized.split("\n").slice(-80).join("\n").trim();
247
+ const blocks = parseAgentOutput(normalized, { tool: session.command }).blocks;
248
+ const lastResponseBlock = blocks.filter((block) => block.type === "response").at(-1);
249
+ const snapshotTurn = lastResponseBlock?.text.trim() ?? "";
247
250
  if (snapshotTurn) {
248
251
  const lastTurn = turns.at(-1);
249
252
  if (!lastTurn || lastTurn.content !== snapshotTurn) {
@@ -3,8 +3,8 @@
3
3
  // Populated by `aimux login` (browser flow). The daemon reads this to connect
4
4
  // to the relay. The token is a long-lived relay-signed JWT — not a Clerk
5
5
  // session token — so it survives daemon restarts and runs for ~90 days.
6
- import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, chmodSync } from "node:fs";
7
- import { dirname } from "node:path";
6
+ import { existsSync, readFileSync, rmSync } from "node:fs";
7
+ import { atomicWrite } from "./atomic-write.js";
8
8
  import { getAuthPath } from "./paths.js";
9
9
  export function loadCredentials() {
10
10
  const path = getAuthPath();
@@ -21,10 +21,7 @@ export function loadCredentials() {
21
21
  }
22
22
  }
23
23
  export function saveCredentials(creds) {
24
- const path = getAuthPath();
25
- mkdirSync(dirname(path), { recursive: true });
26
- writeFileSync(path, `${JSON.stringify(creds, null, 2)}\n`, { mode: 0o600 });
27
- chmodSync(path, 0o600);
24
+ atomicWrite(getAuthPath(), `${JSON.stringify(creds, null, 2)}\n`, { mode: 0o600 });
28
25
  }
29
26
  export function clearCredentials() {
30
27
  const path = getAuthPath();
package/dist/daemon.d.ts CHANGED
@@ -30,6 +30,7 @@ export declare function projectServiceStatus(projectRoot: string): Promise<Proje
30
30
  export declare class AimuxDaemon {
31
31
  private server;
32
32
  private relayClient;
33
+ private readonly pushThrottle;
33
34
  private readonly children;
34
35
  private readonly projectEnsurePromises;
35
36
  private state;
package/dist/daemon.js CHANGED
@@ -9,6 +9,7 @@ import { loadMetadataEndpoint } from "./metadata-store.js";
9
9
  import { requestJson } from "./http-client.js";
10
10
  import { getLoggingConfig, log } from "./debug.js";
11
11
  import { RelayClient } from "./relay-client.js";
12
+ import { MobilePushThrottle } from "./mobile-push-throttle.js";
12
13
  import { loadCredentials, setRemoteEnabled } from "./credentials.js";
13
14
  import { assertRemoteAccessAllowed, parseRemoteActor } from "./remote-access.js";
14
15
  const DEFAULT_DAEMON_PORT = 43190;
@@ -328,6 +329,7 @@ export async function projectServiceStatus(projectRoot) {
328
329
  export class AimuxDaemon {
329
330
  server = null;
330
331
  relayClient = null;
332
+ pushThrottle = new MobilePushThrottle();
331
333
  children = new Map();
332
334
  projectEnsurePromises = new Map();
333
335
  state = loadDaemonState();
@@ -663,6 +665,20 @@ export class AimuxDaemon {
663
665
  if (method === "POST" && pathname === "/relay/disable") {
664
666
  return { status: 200, body: { ok: true, relay: this.disableRelay() } };
665
667
  }
668
+ if (method === "POST" && pathname === "/internal/push") {
669
+ if (actor)
670
+ return { status: 403, body: { ok: false, error: "internal route is loopback-only" } };
671
+ const payload = body;
672
+ if (!payload?.title)
673
+ return { status: 400, body: { ok: false, error: "title is required" } };
674
+ if (this.relayClient?.getStatus().status !== "connected") {
675
+ return { status: 200, body: { ok: true, suppressed: true, reason: "relay_unavailable" } };
676
+ }
677
+ if (!this.pushThrottle.allow(payload))
678
+ return { status: 200, body: { ok: true, suppressed: true } };
679
+ this.relayClient.pushNotification(payload);
680
+ return { status: 200, body: { ok: true } };
681
+ }
666
682
  if (method === "GET" && pathname === "/projects") {
667
683
  const liveById = this.state.projects;
668
684
  const projects = listDesktopProjects().map((project) => ({
@@ -128,6 +128,7 @@ export interface DashboardViewModel {
128
128
  selectedServiceId?: string;
129
129
  selectedTeammates: DashboardSession[];
130
130
  runtimeLabel?: string;
131
+ isDevRuntime?: boolean;
131
132
  mainCheckout: MainCheckoutInfo;
132
133
  worktreeRemoval?: DashboardWorktreeRemovalInfo;
133
134
  operationFailures: DashboardOperationFailure[];
@@ -15,6 +15,7 @@ export class Dashboard {
15
15
  selectedServiceId: undefined,
16
16
  selectedTeammates: [],
17
17
  runtimeLabel: undefined,
18
+ isDevRuntime: false,
18
19
  mainCheckout: { name: "Main Checkout", branch: "" },
19
20
  worktreeRemoval: undefined,
20
21
  operationFailures: [],
@@ -1,12 +1,18 @@
1
1
  import { isDashboardWindowName } from "../tmux/runtime-manager.js";
2
2
  import { getDashboardCommandSpec } from "./command-spec.js";
3
+ import { getRuntimeOwnerId, TMUX_DASHBOARD_OWNER_OPTION, TMUX_RUNTIME_OWNER_OPTION } from "../runtime-owner.js";
3
4
  function isUsableDashboardTarget(tmux, projectRoot, dashboardBuildStamp, dashboardTarget) {
4
5
  const currentBuildStamp = tmux.getWindowOption(dashboardTarget, "@aimux-dashboard-build");
6
+ const currentOwner = getRuntimeOwnerId();
7
+ const targetRuntimeOwner = tmux.getSessionOption(dashboardTarget.sessionName, TMUX_RUNTIME_OWNER_OPTION);
8
+ const targetDashboardOwner = tmux.getWindowOption(dashboardTarget, TMUX_DASHBOARD_OWNER_OPTION);
5
9
  const targetProjectRoot = tmux.getSessionOption(dashboardTarget.sessionName, "@aimux-project-root");
6
10
  const paneCommand = tmux.displayMessage("#{pane_current_command}", dashboardTarget.windowId);
7
11
  const paneTail = paneCommand === "bash" ? tmux.captureTarget(dashboardTarget, { startLine: -40 }) : "";
8
12
  return (tmux.isWindowAlive(dashboardTarget) &&
9
13
  targetProjectRoot === projectRoot &&
14
+ targetRuntimeOwner === currentOwner &&
15
+ targetDashboardOwner === currentOwner &&
10
16
  currentBuildStamp === dashboardBuildStamp &&
11
17
  paneCommand !== "cat" &&
12
18
  paneCommand !== "tail" &&
@@ -110,11 +116,17 @@ export function resolveDashboardTarget(projectRoot, tmux, options = {}) {
110
116
  const openSessionName = tmux.getOpenSessionName(dashboardSession.sessionName, tmux.isInsideTmux());
111
117
  const dashboardTarget = tmux.ensureDashboardWindow(openSessionName, projectRoot, dashboardCommand);
112
118
  const currentBuildStamp = tmux.getWindowOption(dashboardTarget, "@aimux-dashboard-build");
113
- const shouldRespawn = options.forceReload === true || !tmux.isWindowAlive(dashboardTarget) || currentBuildStamp !== dashboardBuildStamp;
119
+ const currentOwner = getRuntimeOwnerId();
120
+ const currentDashboardOwner = tmux.getWindowOption(dashboardTarget, TMUX_DASHBOARD_OWNER_OPTION);
121
+ const shouldRespawn = options.forceReload === true ||
122
+ !tmux.isWindowAlive(dashboardTarget) ||
123
+ currentBuildStamp !== dashboardBuildStamp ||
124
+ currentDashboardOwner !== currentOwner;
114
125
  if (shouldRespawn) {
115
126
  tmux.respawnWindow(dashboardTarget, dashboardCommand);
116
- tmux.setWindowOption(dashboardTarget, "@aimux-dashboard-build", dashboardBuildStamp);
117
127
  }
128
+ tmux.setWindowOption(dashboardTarget, "@aimux-dashboard-build", dashboardBuildStamp);
129
+ tmux.setWindowOption(dashboardTarget, TMUX_DASHBOARD_OWNER_OPTION, currentOwner);
118
130
  return { dashboardSession, dashboardTarget };
119
131
  }
120
132
  export function openDashboardTarget(projectRoot, tmux, options = {}) {
@@ -1,4 +1,5 @@
1
- import { readFileSync, writeFileSync } from "node:fs";
1
+ import { readFileSync } from "node:fs";
2
+ import { writeJsonAtomic } from "../atomic-write.js";
2
3
  import { dashboardOrderKey, moveDashboardOrder, orderDashboardServicesForWorktree, orderDashboardSessionsForWorktree, orderDashboardWorktreeGroups, } from "./order.js";
3
4
  import { getDashboardClientUiStatePath, getDashboardUiStatePath } from "../paths.js";
4
5
  export class DashboardUiStateStore {
@@ -89,8 +90,8 @@ export class DashboardUiStateStore {
89
90
  this.flatSessionId = flatSession.id;
90
91
  }
91
92
  try {
92
- writeFileSync(getDashboardUiStatePath(), JSON.stringify(sharedSnapshot, null, 2) + "\n");
93
- writeFileSync(getDashboardClientUiStatePath(clientKey), JSON.stringify(clientSnapshot, null, 2) + "\n");
93
+ writeJsonAtomic(getDashboardUiStatePath(), sharedSnapshot);
94
+ writeJsonAtomic(getDashboardClientUiStatePath(clientKey), clientSnapshot);
94
95
  }
95
96
  catch { }
96
97
  }
package/dist/last-used.js CHANGED
@@ -1,7 +1,8 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
2
2
  import { getProjectStateDirFor } from "./paths.js";
3
3
  import { join } from "node:path";
4
4
  import { parseRecencyTimestamp } from "./recency.js";
5
+ import { writeJsonAtomic } from "./atomic-write.js";
5
6
  const LAST_USED_VERSION = 1;
6
7
  const MAX_RECENT_IDS = 64;
7
8
  const EMPTY_STATE = {
@@ -66,7 +67,7 @@ export function compareLastUsed(left, right, rankMap) {
66
67
  function persistLastUsedState(projectRoot, state) {
67
68
  const dir = getProjectStateDirFor(projectRoot);
68
69
  mkdirSync(dir, { recursive: true });
69
- writeFileSync(getLastUsedPath(projectRoot), JSON.stringify(state, null, 2));
70
+ writeJsonAtomic(getLastUsedPath(projectRoot), state);
70
71
  }
71
72
  function normalizeLastUsedState(state) {
72
73
  const items = Object.fromEntries(Object.entries(state.items ?? {}).flatMap(([itemId, value]) => typeof value?.lastUsedAt === "string" ? [[itemId, { lastUsedAt: value.lastUsedAt }]] : []));
@@ -0,0 +1,4 @@
1
+ type MutableEnv = Record<string, string | undefined>;
2
+ export declare function prepareStableCliEnv(env?: MutableEnv): void;
3
+ export declare function prepareDevCliEnv(env?: MutableEnv): void;
4
+ export {};
@@ -0,0 +1,70 @@
1
+ import { homedir } from "node:os";
2
+ import { resolve, join } from "node:path";
3
+ const PROD_HOME = join(homedir(), ".aimux");
4
+ const DEV_HOME = join(homedir(), ".aimux-dev");
5
+ const PROD_DAEMON_PORT = "43190";
6
+ const DEV_DAEMON_PORT = "43191";
7
+ const PROD_WEB_APP_URL = "https://aimux.app";
8
+ const DEV_WEB_APP_URL = "http://localhost:8081";
9
+ function normalizeHome(value) {
10
+ const trimmed = value?.trim();
11
+ if (!trimmed)
12
+ return null;
13
+ if (trimmed === "~")
14
+ return homedir();
15
+ if (trimmed.startsWith("~/"))
16
+ return resolve(homedir(), trimmed.slice(2));
17
+ return resolve(trimmed);
18
+ }
19
+ function sameHome(value, expected) {
20
+ const normalized = normalizeHome(value);
21
+ return normalized !== null && normalized === expected;
22
+ }
23
+ function sameValue(value, expected) {
24
+ return value?.trim() === expected;
25
+ }
26
+ function blank(value) {
27
+ return !value?.trim();
28
+ }
29
+ function clearSessionScopedEnv(env) {
30
+ delete env.AIMUX_METADATA_ENDPOINT_FILE;
31
+ delete env.AIMUX_SESSION_ID;
32
+ delete env.AIMUX_SHELL_INTEGRATION_SCRIPT;
33
+ delete env.AIMUX_TOOL;
34
+ }
35
+ export function prepareStableCliEnv(env = process.env) {
36
+ const inheritedDevTarget = sameHome(env.AIMUX_HOME, DEV_HOME) ||
37
+ sameValue(env.AIMUX_DAEMON_PORT, DEV_DAEMON_PORT) ||
38
+ sameValue(env.AIMUX_ENV, "development") ||
39
+ sameValue(env.AIMUX_WEB_APP_URL, DEV_WEB_APP_URL);
40
+ if (blank(env.AIMUX_HOME) || sameHome(env.AIMUX_HOME, DEV_HOME))
41
+ env.AIMUX_HOME = PROD_HOME;
42
+ if (blank(env.AIMUX_DAEMON_PORT) || sameValue(env.AIMUX_DAEMON_PORT, DEV_DAEMON_PORT)) {
43
+ env.AIMUX_DAEMON_PORT = PROD_DAEMON_PORT;
44
+ }
45
+ if (blank(env.AIMUX_ENV) || sameValue(env.AIMUX_ENV, "development"))
46
+ env.AIMUX_ENV = "production";
47
+ if (blank(env.AIMUX_WEB_APP_URL) || sameValue(env.AIMUX_WEB_APP_URL, DEV_WEB_APP_URL)) {
48
+ env.AIMUX_WEB_APP_URL = PROD_WEB_APP_URL;
49
+ }
50
+ if (inheritedDevTarget)
51
+ clearSessionScopedEnv(env);
52
+ }
53
+ export function prepareDevCliEnv(env = process.env) {
54
+ const inheritedStableTarget = sameHome(env.AIMUX_HOME, PROD_HOME) ||
55
+ sameValue(env.AIMUX_DAEMON_PORT, PROD_DAEMON_PORT) ||
56
+ sameValue(env.AIMUX_ENV, "production") ||
57
+ sameValue(env.AIMUX_WEB_APP_URL, PROD_WEB_APP_URL);
58
+ if (blank(env.AIMUX_HOME) || sameHome(env.AIMUX_HOME, PROD_HOME))
59
+ env.AIMUX_HOME = DEV_HOME;
60
+ if (blank(env.AIMUX_DAEMON_PORT) || sameValue(env.AIMUX_DAEMON_PORT, PROD_DAEMON_PORT)) {
61
+ env.AIMUX_DAEMON_PORT = DEV_DAEMON_PORT;
62
+ }
63
+ if (blank(env.AIMUX_ENV) || sameValue(env.AIMUX_ENV, "production"))
64
+ env.AIMUX_ENV = "development";
65
+ if (blank(env.AIMUX_WEB_APP_URL) || sameValue(env.AIMUX_WEB_APP_URL, PROD_WEB_APP_URL)) {
66
+ env.AIMUX_WEB_APP_URL = DEV_WEB_APP_URL;
67
+ }
68
+ if (inheritedStableTarget)
69
+ clearSessionScopedEnv(env);
70
+ }
package/dist/main.js CHANGED
@@ -20,6 +20,7 @@ import { createThread, listThreadSummaries, markThreadSeen, readMessages, readTh
20
20
  import { sendDirectMessage, sendThreadMessage } from "./orchestration.js";
21
21
  import { runLoginFlow } from "./login-flow.js";
22
22
  import { clearCredentials, loadCredentials, setRemoteEnabled } from "./credentials.js";
23
+ import { takeOverProjectFromOtherOwners } from "./project-takeover.js";
23
24
  import { acceptHandoff, approveReview, acceptTask, assignTask, blockTask, completeHandoff, completeTask, reopenTask, requestTaskChanges, sendHandoff, } from "./orchestration-actions.js";
24
25
  import { readAllTasks, readTask } from "./tasks.js";
25
26
  import { clearNotifications, listNotifications, markNotificationsRead, upsertNotification, unreadNotificationCount, } from "./notifications.js";
@@ -37,6 +38,7 @@ import { persistProjectRuntimeSnapshotsBeforeTmuxStop } from "./multiplexer/serv
37
38
  import { configureLogging, log, resolveLoggingRuntimeConfig } from "./debug.js";
38
39
  import { createRuntimeTopologyStore } from "./runtime-core/topology-store.js";
39
40
  import { listTopologySessionStates } from "./runtime-core/topology-sessions.js";
41
+ import { reconcileOfflineBackendSessionIds } from "./runtime-core/backend-id-reconcile.js";
40
42
  import { listTopologyWorktreeGraveyard, listTopologyWorktreeGraveyardPaths, } from "./runtime-core/topology-worktrees.js";
41
43
  import { buildRuntimeMigrationReport, importRuntimeMigration, renderRuntimeMigrationImportResult, renderRuntimeMigrationReport, renderRuntimeMigrationRollbackResult, rollbackRuntimeMigration, } from "./runtime-migration.js";
42
44
  import { DEFAULT_LOCAL_UI_HOST, DEFAULT_LOCAL_UI_PORT, openUrlInBrowser, startLocalUiServer, } from "./local-ui-server.js";
@@ -561,6 +563,8 @@ program
561
563
  const tmux = new TmuxRuntimeManager();
562
564
  ensureTmuxAvailable(tmux);
563
565
  if (!tool && !opts.resume && !opts.restore) {
566
+ await takeOverProjectFromOtherOwners(projectRoot);
567
+ await ensureDaemonProjectReady(projectRoot);
564
568
  const liveDashboard = findLiveDashboardTarget(projectRoot, tmux);
565
569
  if (liveDashboard) {
566
570
  tmux.openTarget(liveDashboard.dashboardTarget, {
@@ -2790,16 +2794,23 @@ repairCmd
2790
2794
  const tmux = new TmuxRuntimeManager();
2791
2795
  ensureTmuxAvailable(tmux);
2792
2796
  const result = repairTmuxRuntime(tmux, { projectRoot });
2797
+ const backendReconcile = reconcileOfflineBackendSessionIds(projectRoot);
2793
2798
  if (opts.open) {
2794
2799
  const { dashboardTarget } = resolveDashboardTarget(projectRoot, tmux);
2795
2800
  tmux.openTarget(dashboardTarget, { insideTmux: tmux.isInsideTmux(), alreadyResolved: true });
2796
2801
  exitAfterOpen();
2797
2802
  }
2798
2803
  if (opts.json) {
2799
- console.log(JSON.stringify(result, null, 2));
2804
+ console.log(JSON.stringify({ ...result, backendReconcile }, null, 2));
2800
2805
  return;
2801
2806
  }
2802
2807
  console.log(renderTmuxRepairResult(result));
2808
+ if (backendReconcile.reconciled.length > 0) {
2809
+ console.log(`Recovered backend session id for ${backendReconcile.reconciled.length} offline agent(s):`);
2810
+ for (const entry of backendReconcile.reconciled) {
2811
+ console.log(` ${entry.id} -> ${entry.backendSessionId}`);
2812
+ }
2813
+ }
2803
2814
  });
2804
2815
  const metadataCmd = program.command("metadata").description("Push metadata into aimux tmux status integration");
2805
2816
  const metadataTracker = new AgentTracker();
@@ -2927,6 +2938,10 @@ program
2927
2938
  const result = { ok: true, action, sessionId };
2928
2939
  if (payload.session_id) {
2929
2940
  result.backendSessionId = payload.session_id;
2941
+ // No local fallback: this hook is a short-lived CLI process, not the
2942
+ // runtime that owns the session, so it cannot record into topology
2943
+ // directly. A service-down capture gap is closed by reconcile-on-restart.
2944
+ await postLiveProjectServiceJsonOrLocal(projectRoot, "/agents/record-backend-session", { sessionId, backendSessionId: payload.session_id }, () => ({ ok: true })).catch(() => { });
2930
2945
  }
2931
2946
  const setActivity = async (activity) => postLiveProjectServiceJsonOrLocal(projectRoot, "/set-activity", { session: sessionId, activity }, () => metadataTracker.setActivity(sessionId, activity, projectRoot));
2932
2947
  const setAttention = async (attention) => postLiveProjectServiceJsonOrLocal(projectRoot, "/set-attention", { session: sessionId, attention }, () => metadataTracker.setAttention(sessionId, attention, projectRoot));
@@ -1,6 +1,7 @@
1
1
  import { type SessionAlertDisplayContext } from "./alert-display.js";
2
2
  import { type MessageKind } from "./threads.js";
3
3
  import { type TaskLifecycleResult } from "./orchestration-actions.js";
4
+ import type { LaunchOverride } from "./shell-args.js";
4
5
  import type { ParsedAgentOutput } from "./agent-output-parser.js";
5
6
  import { ProjectEventBus } from "./project-events.js";
6
7
  interface MetadataServerOptions {
@@ -204,7 +205,7 @@ interface MetadataServerOptions {
204
205
  sessionId?: string;
205
206
  worktreePath?: string;
206
207
  open?: boolean;
207
- extraArgs?: string[];
208
+ launchOverride?: LaunchOverride;
208
209
  }) => Promise<{
209
210
  sessionId: string;
210
211
  }> | {
@@ -242,7 +243,7 @@ interface MetadataServerOptions {
242
243
  instruction?: string;
243
244
  worktreePath?: string;
244
245
  open?: boolean;
245
- extraArgs?: string[];
246
+ launchOverride?: LaunchOverride;
246
247
  }) => Promise<{
247
248
  sessionId: string;
248
249
  threadId: string;
@@ -297,6 +298,16 @@ interface MetadataServerOptions {
297
298
  status: "graveyard";
298
299
  previousStatus: "running" | "offline";
299
300
  };
301
+ recordBackendSessionId?: (input: {
302
+ sessionId: string;
303
+ backendSessionId: string;
304
+ }) => Promise<{
305
+ sessionId: string;
306
+ backendSessionId: string;
307
+ }> | {
308
+ sessionId: string;
309
+ backendSessionId: string;
310
+ };
300
311
  sendAgentInput?: (input: {
301
312
  sessionId: string;
302
313
  text: string;
@@ -3,6 +3,7 @@ import { createHash, randomUUID } from "node:crypto";
3
3
  import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
4
4
  import { dirname, join } from "node:path";
5
5
  import { getDashboardClientUiStatePath, getPlansDir, getProjectId, getProjectStateDir } from "./paths.js";
6
+ import { writeJsonAtomic } from "./atomic-write.js";
6
7
  import { updateSessionMetadata, clearSessionLogs, saveMetadataEndpoint, loadMetadataState, } from "./metadata-store.js";
7
8
  import { contextualizeAlertInput, mergeDisplayContext, metadataDisplayContext, } from "./alert-display.js";
8
9
  import { notifyAlert } from "./notify.js";
@@ -16,7 +17,7 @@ import { readAllTasks, readTask } from "./tasks.js";
16
17
  import { buildWorkflowEntries } from "./workflow.js";
17
18
  import { markLastUsed } from "./last-used.js";
18
19
  import { formatRelativeRecency } from "./recency.js";
19
- import { getAttachment, getAttachmentContent } from "./attachment-store.js";
20
+ import { createUploadedAttachment, getAttachment, getAttachmentContent, getAttachmentRecord, } from "./attachment-store.js";
20
21
  import { ProjectEventBus } from "./project-events.js";
21
22
  import { getProjectServiceManifest } from "./project-service-manifest.js";
22
23
  import { applyShellStateTransition } from "./shell-state.js";
@@ -78,7 +79,7 @@ function persistDashboardClientPreference(clientSession, update) {
78
79
  }
79
80
  catch { }
80
81
  update(snapshot);
81
- writeFileSync(path, JSON.stringify(snapshot, null, 2) + "\n");
82
+ writeJsonAtomic(path, snapshot);
82
83
  }
83
84
  function persistDashboardReturnSelection(tmux, projectRoot, currentClientSession, currentWindowId) {
84
85
  persistDashboardClientPreference(currentClientSession, (snapshot) => {
@@ -177,6 +178,16 @@ function sendBytes(res, status, body, mimeType) {
177
178
  res.setHeader("connection", "close");
178
179
  res.end(body);
179
180
  }
181
+ function formatAgentInputWithAttachments(text, attachments) {
182
+ const trimmedText = text.trim();
183
+ if (attachments.length === 0)
184
+ return text;
185
+ const body = trimmedText || "Please review the attached image file(s).";
186
+ const attachmentLines = attachments.map((attachment) => {
187
+ return `- ${attachment.filename} (${attachment.mimeType}, ${attachment.sizeBytes} bytes): ${attachment.contentPath}`;
188
+ });
189
+ return `${body}\n\nAttached image files:\n${attachmentLines.join("\n")}`;
190
+ }
180
191
  function sendSseEvent(res, event, data) {
181
192
  res.write(`event: ${event}\n`);
182
193
  res.write(`data: ${JSON.stringify(data)}\n\n`);
@@ -1937,6 +1948,17 @@ export class MetadataServer {
1937
1948
  send(res, 200, { ok: true, ...result });
1938
1949
  return;
1939
1950
  }
1951
+ if (req.method === "POST" && url.pathname === "/agents/record-backend-session") {
1952
+ const body = (await readJson(req));
1953
+ if (!this.options.lifecycle?.recordBackendSessionId) {
1954
+ send(res, 501, { ok: false, error: "backend session recording not supported by this service" });
1955
+ return;
1956
+ }
1957
+ const result = await this.options.lifecycle.recordBackendSessionId(body);
1958
+ this.options.onChange?.();
1959
+ send(res, 200, { ok: true, ...result });
1960
+ return;
1961
+ }
1940
1962
  if (req.method === "POST" && url.pathname === "/agents/interrupt") {
1941
1963
  const body = (await readJson(req));
1942
1964
  if (!this.options.lifecycle?.interruptAgent) {
@@ -1989,19 +2011,53 @@ export class MetadataServer {
1989
2011
  return;
1990
2012
  }
1991
2013
  const text = typeof body.text === "string" ? body.text : "";
1992
- if (!text.trim()) {
2014
+ const attachmentIds = Array.isArray(body.attachmentIds)
2015
+ ? body.attachmentIds
2016
+ .filter((id) => typeof id === "string")
2017
+ .map((id) => id.trim())
2018
+ .filter(Boolean)
2019
+ : [];
2020
+ if (!text.trim() && attachmentIds.length === 0) {
1993
2021
  send(res, 400, { ok: false, error: "text is required" });
1994
2022
  return;
1995
2023
  }
2024
+ const attachments = attachmentIds.map((id) => getAttachmentRecord(id));
2025
+ const missingAttachmentId = attachmentIds.find((_, index) => attachments[index] === null);
2026
+ if (missingAttachmentId) {
2027
+ send(res, 400, { ok: false, error: `attachment not found: ${missingAttachmentId}` });
2028
+ return;
2029
+ }
1996
2030
  if (!this.options.lifecycle?.sendAgentInput) {
1997
2031
  send(res, 501, { ok: false, error: "agent input not supported by this service" });
1998
2032
  return;
1999
2033
  }
2000
- const result = await this.options.lifecycle.sendAgentInput({ sessionId, text });
2034
+ const formattedText = formatAgentInputWithAttachments(text, attachments.filter((entry) => !!entry));
2035
+ const result = await this.options.lifecycle.sendAgentInput({ sessionId, text: formattedText });
2001
2036
  this.options.onChange?.();
2002
2037
  send(res, 200, { ok: true, ...result });
2003
2038
  return;
2004
2039
  }
2040
+ if (req.method === "POST" && url.pathname === "/attachments") {
2041
+ const body = (await readJson(req));
2042
+ if (typeof body.filename !== "string" ||
2043
+ typeof body.mimeType !== "string" ||
2044
+ typeof body.dataBase64 !== "string") {
2045
+ send(res, 400, { ok: false, error: "filename, mimeType, and dataBase64 are required" });
2046
+ return;
2047
+ }
2048
+ try {
2049
+ const attachment = createUploadedAttachment({
2050
+ filename: body.filename,
2051
+ mimeType: body.mimeType,
2052
+ dataBase64: body.dataBase64,
2053
+ });
2054
+ send(res, 200, { ok: true, attachment });
2055
+ }
2056
+ catch (error) {
2057
+ send(res, 400, { ok: false, error: error instanceof Error ? error.message : "invalid attachment" });
2058
+ }
2059
+ return;
2060
+ }
2005
2061
  const attachmentContentMatch = url.pathname.match(/^\/attachments\/([^/]+)\/content$/);
2006
2062
  if (req.method === "GET" && attachmentContentMatch) {
2007
2063
  const content = getAttachmentContent(decodeURIComponent(attachmentContentMatch[1] || ""));
@@ -1,6 +1,6 @@
1
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
- import { writeJsonAtomic } from "./atomic-write.js";
3
+ import { quarantineCorruptFile, writeJsonAtomic, writeTextAtomic } from "./atomic-write.js";
4
4
  import { getProjectStateDir, getProjectStateDirFor } from "./paths.js";
5
5
  function ensureParent(path) {
6
6
  mkdirSync(dirname(path), { recursive: true });
@@ -21,6 +21,7 @@ function loadJson(path, fallback) {
21
21
  return JSON.parse(readFileSync(path, "utf-8"));
22
22
  }
23
23
  catch {
24
+ quarantineCorruptFile(path);
24
25
  return fallback;
25
26
  }
26
27
  }
@@ -94,7 +95,7 @@ export function saveMetadataEndpoint(endpoint, projectRoot) {
94
95
  saveJson(endpointPathFor(projectRoot), endpoint);
95
96
  const textPath = endpointTextPathFor(projectRoot);
96
97
  ensureParent(textPath);
97
- writeFileSync(textPath, `http://${endpoint.host}:${endpoint.port}\n`);
98
+ writeTextAtomic(textPath, `http://${endpoint.host}:${endpoint.port}\n`);
98
99
  }
99
100
  export function removeMetadataEndpoint(projectRoot) {
100
101
  try {
@@ -0,0 +1,8 @@
1
+ import type { AlertEvent } from "./project-events.js";
2
+ /**
3
+ * Forwards an alert that already passed desktop notification gating to the
4
+ * daemon, which relays it to the owner's mobile devices. Fire-and-forget: the
5
+ * daemon owns the single relay connection, so the project service hands off and
6
+ * never blocks the alert path on push delivery.
7
+ */
8
+ export declare function forwardAlertToMobilePush(event: AlertEvent): void;
@@ -0,0 +1,22 @@
1
+ import { requestDaemonJson } from "./daemon.js";
2
+ /**
3
+ * Forwards an alert that already passed desktop notification gating to the
4
+ * daemon, which relays it to the owner's mobile devices. Fire-and-forget: the
5
+ * daemon owns the single relay connection, so the project service hands off and
6
+ * never blocks the alert path on push delivery.
7
+ */
8
+ export function forwardAlertToMobilePush(event) {
9
+ void requestDaemonJson("/internal/push", {
10
+ method: "POST",
11
+ body: JSON.stringify({
12
+ title: event.title || "aimux",
13
+ body: event.message || event.sessionId || event.kind,
14
+ kind: event.kind,
15
+ sessionId: event.sessionId,
16
+ projectId: event.projectId,
17
+ projectRoot: process.cwd(),
18
+ dedupeKey: event.dedupeKey,
19
+ }),
20
+ headers: { "content-type": "application/json" },
21
+ }).catch(() => { });
22
+ }
@@ -0,0 +1,23 @@
1
+ export interface ThrottleInput {
2
+ dedupeKey?: string;
3
+ sessionId?: string;
4
+ kind?: string;
5
+ title?: string;
6
+ body?: string;
7
+ }
8
+ /**
9
+ * In-memory guard for outbound mobile pushes. Collapses identical alerts
10
+ * re-emitted within a TTL window (chatty idle/needs_input polling) and caps the
11
+ * push rate per session so one runaway agent cannot flood the device.
12
+ */
13
+ export declare class MobilePushThrottle {
14
+ private readonly dedupeTtlMs;
15
+ private readonly sessionLimit;
16
+ private readonly sessionWindowMs;
17
+ private readonly now;
18
+ private readonly lastByKey;
19
+ private readonly sessionHits;
20
+ constructor(dedupeTtlMs?: number, sessionLimit?: number, sessionWindowMs?: number, now?: () => number);
21
+ allow(input: ThrottleInput): boolean;
22
+ private prune;
23
+ }