ftown-bridge 0.11.2 → 0.12.0

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.
@@ -0,0 +1,128 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { v4 as uuidv4 } from 'uuid';
5
+ import { computeNextRun } from './loop-schedule.js';
6
+ function loopsPath() {
7
+ return join(homedir(), '.ftown', 'loops.json');
8
+ }
9
+ /** Tolerant loader: returns { loops: [] } on a missing OR corrupt file. Never throws. */
10
+ function loadLoops() {
11
+ try {
12
+ const path = loopsPath();
13
+ if (!existsSync(path))
14
+ return { loops: [] };
15
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
16
+ return { loops: Array.isArray(parsed.loops) ? parsed.loops : [] };
17
+ }
18
+ catch {
19
+ return { loops: [] };
20
+ }
21
+ }
22
+ function saveLoops(data) {
23
+ mkdirSync(join(homedir(), '.ftown'), { recursive: true, mode: 0o700 });
24
+ const path = loopsPath();
25
+ const tmp = `${path}.tmp`;
26
+ writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
27
+ renameSync(tmp, path); // atomic
28
+ }
29
+ export function listLoops() {
30
+ return loadLoops().loops;
31
+ }
32
+ export function getLoop(id) {
33
+ return loadLoops().loops.find((loop) => loop.id === id);
34
+ }
35
+ /**
36
+ * Mint a Loop from a client draft: fresh id/timestamps, zeroed counters, and a
37
+ * nextRunAt computed from now — even when created disabled, so re-enabling has a
38
+ * target. Throws (via computeNextRun) on a malformed cron expression.
39
+ */
40
+ export function createLoop(draft) {
41
+ const data = loadLoops();
42
+ const now = new Date();
43
+ const loop = {
44
+ ...draft,
45
+ id: uuidv4(),
46
+ createdAt: now.toISOString(),
47
+ updatedAt: now.toISOString(),
48
+ nextRunAt: new Date(computeNextRun(draft.schedule, now.getTime())).toISOString(),
49
+ runCount: 0,
50
+ skipCount: 0,
51
+ };
52
+ data.loops.push(loop);
53
+ saveLoops(data);
54
+ return loop;
55
+ }
56
+ /**
57
+ * Merge `patch` over an existing loop. id + createdAt are immutable; updatedAt is
58
+ * bumped. A schedule change recomputes nextRunAt from now (the old target is
59
+ * stale). Returns null when no loop has that id.
60
+ */
61
+ export function updateLoop(id, patch) {
62
+ const data = loadLoops();
63
+ const index = data.loops.findIndex((loop) => loop.id === id);
64
+ if (index === -1)
65
+ return null;
66
+ const existing = data.loops[index];
67
+ const updated = {
68
+ ...existing,
69
+ ...patch,
70
+ id: existing.id,
71
+ // A loop is owned by exactly one bridge (the one it is persisted on); its
72
+ // bridgeId is the RPC routing key. Never let a patch move it — a stray
73
+ // patch.bridgeId would otherwise desync the record from the routing guard.
74
+ bridgeId: existing.bridgeId,
75
+ createdAt: existing.createdAt,
76
+ updatedAt: new Date().toISOString(),
77
+ };
78
+ if (patch.schedule) {
79
+ updated.nextRunAt = new Date(computeNextRun(updated.schedule, Date.now())).toISOString();
80
+ }
81
+ data.loops[index] = updated;
82
+ saveLoops(data);
83
+ return updated;
84
+ }
85
+ export function deleteLoop(id) {
86
+ const data = loadLoops();
87
+ const remaining = data.loops.filter((loop) => loop.id !== id);
88
+ if (remaining.length === data.loops.length)
89
+ return false;
90
+ data.loops = remaining;
91
+ saveLoops(data);
92
+ return true;
93
+ }
94
+ /** Insert a loop, or replace the existing one with the same id, in place. */
95
+ export function upsertLoop(loop) {
96
+ const data = loadLoops();
97
+ const index = data.loops.findIndex((existing) => existing.id === loop.id);
98
+ if (index === -1)
99
+ data.loops.push(loop);
100
+ else
101
+ data.loops[index] = loop;
102
+ saveLoops(data);
103
+ }
104
+ /**
105
+ * Atomically apply a scheduler-owned mutation: reload the loop FRESH from disk,
106
+ * apply `fn`, then save. Returns the merged loop, or null when the id no longer
107
+ * exists (deleted concurrently) — the caller then skips its publish so a deleted
108
+ * loop is never resurrected.
109
+ *
110
+ * The scheduler holds a detached Loop snapshot across long awaits (a 20s
111
+ * preflight, an in-process spawn). Writing that whole stale snapshot back would
112
+ * (a) resurrect a loop deleted mid-flight and (b) clobber a concurrent
113
+ * update_loop patch to user-owned fields. Because `fn` runs on the
114
+ * freshly-loaded record and touches only runtime fields, both hazards are gone:
115
+ * a delete wins (null), and an unrelated enabled/schedule/task patch survives.
116
+ */
117
+ export function mutateLoopRuntime(id, fn) {
118
+ const data = loadLoops();
119
+ const index = data.loops.findIndex((loop) => loop.id === id);
120
+ if (index === -1)
121
+ return null;
122
+ const loop = data.loops[index];
123
+ fn(loop);
124
+ data.loops[index] = loop;
125
+ saveLoops(data);
126
+ return loop;
127
+ }
128
+ //# sourceMappingURL=loop-store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"loop-store.js","sourceRoot":"","sources":["../src/loop-store.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,SAAS,EAAE,YAAY,EAAE,UAAU,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AACzF,OAAO,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAClC,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,OAAO,EAAE,EAAE,IAAI,MAAM,EAAE,MAAM,MAAM,CAAC;AAEpC,OAAO,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AAgBpD,SAAS,SAAS;IAChB,OAAO,IAAI,CAAC,OAAO,EAAE,EAAE,QAAQ,EAAE,YAAY,CAAC,CAAC;AACjD,CAAC;AAED,yFAAyF;AACzF,SAAS,SAAS;IAChB,IAAI,CAAC;QACH,MAAM,IAAI,GAAG,SAAS,EAAE,CAAC;QACzB,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC;YAAE,OAAO,EAAE,KAAK,EAAE,EAAE,EAAE,CAAC;QAC5C,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC,CAAuB,CAAC;QAC5E,OAAO,EAAE,KAAK,EAAE,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC;IACpE,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,EAAE,KAAK,EAAE,EAAE,EAAE,CAAC;IACvB,CAAC;AACH,CAAC;AAED,SAAS,SAAS,CAAC,IAAe;IAChC,SAAS,CAAC,IAAI,CAAC,OAAO,EAAE,EAAE,QAAQ,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;IACvE,MAAM,IAAI,GAAG,SAAS,EAAE,CAAC;IACzB,MAAM,GAAG,GAAG,GAAG,IAAI,MAAM,CAAC;IAC1B,aAAa,CAAC,GAAG,EAAE,IAAI,CAAC,SAAS,CAAC,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC,GAAG,IAAI,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;IAC1E,UAAU,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC,CAAC,SAAS;AAClC,CAAC;AAED,MAAM,UAAU,SAAS;IACvB,OAAO,SAAS,EAAE,CAAC,KAAK,CAAC;AAC3B,CAAC;AAED,MAAM,UAAU,OAAO,CAAC,EAAU;IAChC,OAAO,SAAS,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC;AAC1D,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,UAAU,CAAC,KAAgB;IACzC,MAAM,IAAI,GAAG,SAAS,EAAE,CAAC;IACzB,MAAM,GAAG,GAAG,IAAI,IAAI,EAAE,CAAC;IACvB,MAAM,IAAI,GAAS;QACjB,GAAG,KAAK;QACR,EAAE,EAAE,MAAM,EAAE;QACZ,SAAS,EAAE,GAAG,CAAC,WAAW,EAAE;QAC5B,SAAS,EAAE,GAAG,CAAC,WAAW,EAAE;QAC5B,SAAS,EAAE,IAAI,IAAI,CAAC,cAAc,CAAC,KAAK,CAAC,QAAQ,EAAE,GAAG,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,WAAW,EAAE;QAChF,QAAQ,EAAE,CAAC;QACX,SAAS,EAAE,CAAC;KACb,CAAC;IACF,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACtB,SAAS,CAAC,IAAI,CAAC,CAAC;IAChB,OAAO,IAAI,CAAC;AACd,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,UAAU,CAAC,EAAU,EAAE,KAAyB;IAC9D,MAAM,IAAI,GAAG,SAAS,EAAE,CAAC;IACzB,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC;IAC7D,IAAI,KAAK,KAAK,CAAC,CAAC;QAAE,OAAO,IAAI,CAAC;IAE9B,MAAM,QAAQ,GAAG,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;IACnC,MAAM,OAAO,GAAS;QACpB,GAAG,QAAQ;QACX,GAAG,KAAK;QACR,EAAE,EAAE,QAAQ,CAAC,EAAE;QACf,0EAA0E;QAC1E,uEAAuE;QACvE,2EAA2E;QAC3E,QAAQ,EAAE,QAAQ,CAAC,QAAQ;QAC3B,SAAS,EAAE,QAAQ,CAAC,SAAS;QAC7B,SAAS,EAAE,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE;KACpC,CAAC;IACF,IAAI,KAAK,CAAC,QAAQ,EAAE,CAAC;QACnB,OAAO,CAAC,SAAS,GAAG,IAAI,IAAI,CAAC,cAAc,CAAC,OAAO,CAAC,QAAQ,EAAE,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC,WAAW,EAAE,CAAC;IAC3F,CAAC;IAED,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,GAAG,OAAO,CAAC;IAC5B,SAAS,CAAC,IAAI,CAAC,CAAC;IAChB,OAAO,OAAO,CAAC;AACjB,CAAC;AAED,MAAM,UAAU,UAAU,CAAC,EAAU;IACnC,MAAM,IAAI,GAAG,SAAS,EAAE,CAAC;IACzB,MAAM,SAAS,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC;IAC9D,IAAI,SAAS,CAAC,MAAM,KAAK,IAAI,CAAC,KAAK,CAAC,MAAM;QAAE,OAAO,KAAK,CAAC;IACzD,IAAI,CAAC,KAAK,GAAG,SAAS,CAAC;IACvB,SAAS,CAAC,IAAI,CAAC,CAAC;IAChB,OAAO,IAAI,CAAC;AACd,CAAC;AAED,6EAA6E;AAC7E,MAAM,UAAU,UAAU,CAAC,IAAU;IACnC,MAAM,IAAI,GAAG,SAAS,EAAE,CAAC;IACzB,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC,QAAQ,CAAC,EAAE,KAAK,IAAI,CAAC,EAAE,CAAC,CAAC;IAC1E,IAAI,KAAK,KAAK,CAAC,CAAC;QAAE,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;;QACnC,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,GAAG,IAAI,CAAC;IAC9B,SAAS,CAAC,IAAI,CAAC,CAAC;AAClB,CAAC;AAOD;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,iBAAiB,CAAC,EAAU,EAAE,EAAsB;IAClE,MAAM,IAAI,GAAG,SAAS,EAAE,CAAC;IACzB,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC;IAC7D,IAAI,KAAK,KAAK,CAAC,CAAC;QAAE,OAAO,IAAI,CAAC;IAC9B,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;IAC/B,EAAE,CAAC,IAAI,CAAC,CAAC;IACT,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,GAAG,IAAI,CAAC;IACzB,SAAS,CAAC,IAAI,CAAC,CAAC;IAChB,OAAO,IAAI,CAAC;AACd,CAAC"}
@@ -0,0 +1,14 @@
1
+ import type { LoopDraft, LoopSchedule } from './types.js';
2
+ /**
3
+ * Loop payload validation, extracted from index.ts so it is unit-testable
4
+ * without importing the CLI entrypoint (which calls program.parse() on load).
5
+ * Every validator returns an error string, or null when the input is valid.
6
+ */
7
+ export declare const LOOP_HARNESSES: ReadonlySet<string>;
8
+ /** Validate a loop schedule (interval floor + cron parseability). */
9
+ export declare function validateSchedule(schedule: LoopSchedule | undefined): string | null;
10
+ export declare function validateRetention(retention: LoopDraft['retention'] | undefined): string | null;
11
+ /** Full-draft validation for create_loop. */
12
+ export declare function validateLoopDraft(draft: Partial<LoopDraft>): string | null;
13
+ /** Partial validation for update_loop — only the fields present in the patch. */
14
+ export declare function validateLoopPatch(patch: Partial<LoopDraft>): string | null;
@@ -0,0 +1,95 @@
1
+ import { computeNextRun } from './loop-schedule.js';
2
+ /**
3
+ * Loop payload validation, extracted from index.ts so it is unit-testable
4
+ * without importing the CLI entrypoint (which calls program.parse() on load).
5
+ * Every validator returns an error string, or null when the input is valid.
6
+ */
7
+ export const LOOP_HARNESSES = new Set([
8
+ 'claude',
9
+ 'cursor',
10
+ 'codex',
11
+ 'opencode',
12
+ 'shell',
13
+ ]);
14
+ /** Validate a loop schedule (interval floor + cron parseability). */
15
+ export function validateSchedule(schedule) {
16
+ if (!schedule || typeof schedule !== 'object')
17
+ return 'schedule is required';
18
+ if (schedule.kind === 'interval') {
19
+ if (typeof schedule.everyMs !== 'number' || !Number.isFinite(schedule.everyMs) || schedule.everyMs < 1000) {
20
+ return 'interval everyMs must be a finite number >= 1000';
21
+ }
22
+ return null;
23
+ }
24
+ if (schedule.kind === 'cron') {
25
+ if (typeof schedule.expression !== 'string' || !schedule.expression.trim()) {
26
+ return 'cron expression is required';
27
+ }
28
+ try {
29
+ computeNextRun(schedule, Date.now());
30
+ }
31
+ catch {
32
+ return `Invalid cron expression: ${schedule.expression}`;
33
+ }
34
+ return null;
35
+ }
36
+ return 'schedule.kind must be "interval" or "cron"';
37
+ }
38
+ export function validateRetention(retention) {
39
+ const value = retention?.autoClearAfterRuns;
40
+ const ok = value === null || (typeof value === 'number' && Number.isFinite(value) && value >= 0);
41
+ return retention && ok ? null : 'retention.autoClearAfterRuns must be null or a non-negative number';
42
+ }
43
+ /** Full-draft validation for create_loop. */
44
+ export function validateLoopDraft(draft) {
45
+ if (!draft || typeof draft !== 'object')
46
+ return 'Invalid loop payload';
47
+ // Required so a create can never slip past the bridge-routing guard and get
48
+ // duplicated across every connected bridge (create mints a fresh id).
49
+ if (typeof draft.bridgeId !== 'string' || !draft.bridgeId.trim())
50
+ return 'bridgeId is required';
51
+ if (typeof draft.name !== 'string' || !draft.name.trim())
52
+ return 'Loop name is required';
53
+ if (typeof draft.task !== 'string' || !draft.task.trim())
54
+ return 'Loop task is required';
55
+ if (typeof draft.harness !== 'string' || !LOOP_HARNESSES.has(draft.harness)) {
56
+ return `Invalid harness: ${String(draft.harness)}`;
57
+ }
58
+ if (draft.overlapPolicy !== 'skip' && draft.overlapPolicy !== 'allow') {
59
+ return 'overlapPolicy must be "skip" or "allow"';
60
+ }
61
+ if (typeof draft.enabled !== 'boolean')
62
+ return 'enabled must be a boolean';
63
+ const retentionError = validateRetention(draft.retention);
64
+ if (retentionError)
65
+ return retentionError;
66
+ return validateSchedule(draft.schedule);
67
+ }
68
+ /** Partial validation for update_loop — only the fields present in the patch. */
69
+ export function validateLoopPatch(patch) {
70
+ if (!patch || typeof patch !== 'object')
71
+ return 'Invalid patch';
72
+ if ('name' in patch && (typeof patch.name !== 'string' || !patch.name.trim())) {
73
+ return 'Loop name must be a non-empty string';
74
+ }
75
+ if ('task' in patch && (typeof patch.task !== 'string' || !patch.task.trim())) {
76
+ return 'Loop task must be a non-empty string';
77
+ }
78
+ if ('harness' in patch && (typeof patch.harness !== 'string' || !LOOP_HARNESSES.has(patch.harness))) {
79
+ return `Invalid harness: ${String(patch.harness)}`;
80
+ }
81
+ if ('overlapPolicy' in patch && patch.overlapPolicy !== 'skip' && patch.overlapPolicy !== 'allow') {
82
+ return 'overlapPolicy must be "skip" or "allow"';
83
+ }
84
+ if ('enabled' in patch && typeof patch.enabled !== 'boolean')
85
+ return 'enabled must be a boolean';
86
+ if ('retention' in patch) {
87
+ const retentionError = validateRetention(patch.retention);
88
+ if (retentionError)
89
+ return retentionError;
90
+ }
91
+ if ('schedule' in patch)
92
+ return validateSchedule(patch.schedule);
93
+ return null;
94
+ }
95
+ //# sourceMappingURL=loop-validation.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"loop-validation.js","sourceRoot":"","sources":["../src/loop-validation.ts"],"names":[],"mappings":"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"}
package/dist/types.d.ts CHANGED
@@ -19,12 +19,72 @@ export interface Session {
19
19
  parentSessionId?: string;
20
20
  runtime?: SessionRuntime;
21
21
  errorReason?: string;
22
+ loopId?: string;
22
23
  }
23
24
  export type SessionStatus = 'pending' | 'running' | 'completed' | 'error';
24
25
  /** Tombstone written to <dataDir>/archive.jsonl when a session is removed. */
25
26
  export interface ArchivedSession extends Session {
26
27
  removedAt: string;
27
28
  }
29
+ export type LoopHarness = 'claude' | 'cursor' | 'codex' | 'opencode' | 'shell';
30
+ export type LoopRunStatus = 'ok' | 'error' | 'running' | 'skipped';
31
+ export type LoopSchedule = {
32
+ kind: 'interval';
33
+ everyMs: number;
34
+ } | {
35
+ kind: 'cron';
36
+ expression: string;
37
+ tz?: string;
38
+ };
39
+ export interface LoopFlight {
40
+ command: string;
41
+ timeoutMs?: number;
42
+ }
43
+ export interface LoopPostflight {
44
+ command: string;
45
+ timeoutMs?: number;
46
+ /** Run postflight even when the fire was preflight-skipped. Default false. */
47
+ runOnSkip?: boolean;
48
+ }
49
+ export interface LoopRetention {
50
+ /** Keep newest N run-sessions; prune older ones. null = keep all. Default 10. */
51
+ autoClearAfterRuns: number | null;
52
+ }
53
+ /** Client-authored fields (create/edit form). */
54
+ export interface LoopDraft {
55
+ name: string;
56
+ bridgeId: string;
57
+ schedule: LoopSchedule;
58
+ harness: LoopHarness;
59
+ workdir?: string;
60
+ task: string;
61
+ model?: string;
62
+ enabled: boolean;
63
+ overlapPolicy: 'skip' | 'allow';
64
+ retention: LoopRetention;
65
+ preflight?: LoopFlight;
66
+ postflight?: LoopPostflight;
67
+ /** Optional deterministic backstop: force-stop + mark 'error' if a flight runs longer. */
68
+ maxRuntimeMs?: number;
69
+ }
70
+ /** Full server-authoritative record (LoopDraft + runtime state). */
71
+ export interface Loop extends LoopDraft {
72
+ id: string;
73
+ createdAt: string;
74
+ updatedAt: string;
75
+ lastRunAt?: string;
76
+ nextRunAt?: string;
77
+ lastStatus?: LoopRunStatus;
78
+ lastSessionId?: string;
79
+ runCount: number;
80
+ skipCount: number;
81
+ /** Transient manual-fire flag; set by run_loop_now, cleared on the next tick. */
82
+ runNowRequested?: boolean;
83
+ }
84
+ /** A "run" is exactly a Session whose loopId === loop.id. No separate store record. */
85
+ export type LoopRun = Session & {
86
+ loopId: string;
87
+ };
28
88
  /** Inter-agent mail stored in <dataDir>/sessions/<id>/inbox.jsonl. */
29
89
  export interface MailMessage {
30
90
  id: string;
@@ -52,7 +112,7 @@ export interface Command {
52
112
  payload: CommandPayload;
53
113
  requestId: string;
54
114
  }
55
- export type CommandType = 'create_session' | 'stop_session' | 'list_sessions' | 'get_history' | 'retry_session' | 'send_message' | 'rename_session' | 'remove_session' | 'bridge_exec' | 'clear_terminal' | 'update_session_parent';
115
+ export type CommandType = 'create_session' | 'stop_session' | 'list_sessions' | 'get_history' | 'retry_session' | 'send_message' | 'rename_session' | 'remove_session' | 'bridge_exec' | 'clear_terminal' | 'update_session_parent' | 'create_loop' | 'list_loops' | 'update_loop' | 'delete_loop' | 'run_loop_now' | 'get_loop_runs';
56
116
  export interface CreateSessionPayload {
57
117
  command: string;
58
118
  prompt?: string;
@@ -101,7 +161,30 @@ export interface RemoveSessionPayload {
101
161
  export interface ClearTerminalPayload {
102
162
  sessionId: string;
103
163
  }
104
- export type CommandPayload = CreateSessionPayload | StopSessionPayload | GetHistoryPayload | RenameSessionPayload | RemoveSessionPayload | BridgeExecPayload | ClearTerminalPayload | UpdateSessionParentPayload | Record<string, unknown>;
164
+ export interface CreateLoopPayload extends LoopDraft {
165
+ bridgeId: string;
166
+ }
167
+ export interface ListLoopsPayload {
168
+ bridgeId?: string;
169
+ }
170
+ export interface UpdateLoopPayload {
171
+ bridgeId: string;
172
+ loopId: string;
173
+ patch: Partial<LoopDraft>;
174
+ }
175
+ export interface DeleteLoopPayload {
176
+ bridgeId: string;
177
+ loopId: string;
178
+ }
179
+ export interface RunLoopNowPayload {
180
+ bridgeId: string;
181
+ loopId: string;
182
+ }
183
+ export interface GetLoopRunsPayload {
184
+ bridgeId: string;
185
+ loopId: string;
186
+ }
187
+ export type CommandPayload = CreateSessionPayload | StopSessionPayload | GetHistoryPayload | RenameSessionPayload | RemoveSessionPayload | BridgeExecPayload | ClearTerminalPayload | UpdateSessionParentPayload | CreateLoopPayload | ListLoopsPayload | UpdateLoopPayload | DeleteLoopPayload | RunLoopNowPayload | GetLoopRunsPayload | Record<string, unknown>;
105
188
  export interface CommandResponse {
106
189
  requestId: string;
107
190
  success: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ftown-bridge",
3
- "version": "0.11.2",
3
+ "version": "0.12.0",
4
4
  "description": "CLI bridge for ftown — generic PTY-over-Centrifugo relay",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -48,6 +48,7 @@
48
48
  "@xterm/headless": "^6.0.0",
49
49
  "centrifuge": "^5.2.2",
50
50
  "commander": "^13.1.0",
51
+ "cron-parser": "^4.9.0",
51
52
  "node-pty": "^1.2.0-beta.12",
52
53
  "uuid": "^11.1.0",
53
54
  "ws": "^8.18.0"