janissary 0.5.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.
- package/LICENSE +75 -0
- package/README.md +28 -0
- package/agent-names.json +52 -0
- package/bin/janus.mjs +27 -0
- package/chrome-extension/_metadata/generated_indexed_rulesets/_ruleset1 +0 -0
- package/chrome-extension/content-script.js +62 -0
- package/chrome-extension/manifest.json +25 -0
- package/chrome-extension/rules.json +16 -0
- package/dist/acp/index.js +93 -0
- package/dist/acp/loop.js +75 -0
- package/dist/acp/manager.js +126 -0
- package/dist/acp/rate-limit.js +8 -0
- package/dist/acp/runner.js +18 -0
- package/dist/acp/tools.js +27 -0
- package/dist/acp-loop.js +75 -0
- package/dist/acp-manager.js +109 -0
- package/dist/acp-runner.js +18 -0
- package/dist/acp.js +91 -0
- package/dist/agent/commands.js +29 -0
- package/dist/agent/communication-manager.js +71 -0
- package/dist/agent/state.js +58 -0
- package/dist/agent-commands.js +29 -0
- package/dist/agent-communication-manager.js +71 -0
- package/dist/agent-state.js +58 -0
- package/dist/app-themes.js +13 -0
- package/dist/browser/command.js +104 -0
- package/dist/browser/index.js +124 -0
- package/dist/browser/tab-helpers.js +51 -0
- package/dist/browser/tab.js +99 -0
- package/dist/browser-command.js +104 -0
- package/dist/browser-tab-helpers.js +51 -0
- package/dist/browser-tab.js +99 -0
- package/dist/browser.js +124 -0
- package/dist/buffer.js +42 -0
- package/dist/bus.js +52 -0
- package/dist/capture/manager.js +48 -0
- package/dist/capture/router.js +18 -0
- package/dist/capture-manager.js +48 -0
- package/dist/capture-router.js +20 -0
- package/dist/chrome-extension-loader.js +62 -0
- package/dist/cli-args.js +80 -0
- package/dist/command-manager.js +126 -0
- package/dist/command-router.js +14 -0
- package/dist/commands/acp-reset.js +13 -0
- package/dist/commands/acp.js +5 -0
- package/dist/commands/agent.js +5 -0
- package/dist/commands/broadcast.js +22 -0
- package/dist/commands/browser.js +5 -0
- package/dist/commands/clear.js +5 -0
- package/dist/commands/close.js +42 -0
- package/dist/commands/connection.js +5 -0
- package/dist/commands/db.js +5 -0
- package/dist/commands/edit.js +18 -0
- package/dist/commands/files.js +13 -0
- package/dist/commands/hist.js +7 -0
- package/dist/commands/index.js +62 -0
- package/dist/commands/monitor.js +61 -0
- package/dist/commands/msg.js +17 -0
- package/dist/commands/next.js +7 -0
- package/dist/commands/notifications.js +16 -0
- package/dist/commands/notify.js +20 -0
- package/dist/commands/open.js +41 -0
- package/dist/commands/profile.js +5 -0
- package/dist/commands/queue.js +37 -0
- package/dist/commands/quit.js +6 -0
- package/dist/commands/rename.js +12 -0
- package/dist/commands/resolve-target.js +12 -0
- package/dist/commands/schedule.js +74 -0
- package/dist/commands/search.js +26 -0
- package/dist/commands/send.js +51 -0
- package/dist/commands/state.js +9 -0
- package/dist/commands/syntax.js +36 -0
- package/dist/commands/tasks.js +7 -0
- package/dist/commands/theme.js +41 -0
- package/dist/commands/types.js +1 -0
- package/dist/commands.js +70 -0
- package/dist/completion/browser.js +24 -0
- package/dist/completion/fs.js +26 -0
- package/dist/completion/handlers.js +81 -0
- package/dist/completion/helpers.js +43 -0
- package/dist/completion/index.js +43 -0
- package/dist/completion-browser.js +24 -0
- package/dist/completion-fs.js +26 -0
- package/dist/completion-handlers.js +69 -0
- package/dist/completion-helpers.js +43 -0
- package/dist/completion.js +42 -0
- package/dist/config.js +66 -0
- package/dist/connection/close.js +41 -0
- package/dist/connection/manager.js +93 -0
- package/dist/connection/parsing.js +28 -0
- package/dist/connection-close.js +41 -0
- package/dist/connection-manager.js +93 -0
- package/dist/connection-parsing.js +28 -0
- package/dist/connections.js +75 -0
- package/dist/controller-events.js +34 -0
- package/dist/controller-file-tree.js +36 -0
- package/dist/controller.js +201 -0
- package/dist/database/index.js +83 -0
- package/dist/database/manager.js +67 -0
- package/dist/database/parsing.js +54 -0
- package/dist/database/query.js +41 -0
- package/dist/database-manager.js +67 -0
- package/dist/database-parsing.js +54 -0
- package/dist/database-query.js +41 -0
- package/dist/database.js +83 -0
- package/dist/datetime.js +15 -0
- package/dist/editor/save.js +27 -0
- package/dist/editor/sync.js +10 -0
- package/dist/editor/watch-manager.js +84 -0
- package/dist/editor-save.js +24 -0
- package/dist/editor-watch-manager.js +84 -0
- package/dist/file-tree-args.js +36 -0
- package/dist/file-tree-git-refresh.js +33 -0
- package/dist/file-tree-manager.js +252 -0
- package/dist/file-tree-moves.js +26 -0
- package/dist/file-tree-open.js +56 -0
- package/dist/file-tree-rebuild.js +22 -0
- package/dist/file-tree-watch.js +27 -0
- package/dist/file-tree.js +82 -0
- package/dist/git-status.js +64 -0
- package/dist/github-token.js +19 -0
- package/dist/global-history.js +48 -0
- package/dist/harness/auto-approve.js +79 -0
- package/dist/harness/busy-status.js +91 -0
- package/dist/harness/capture-file.js +28 -0
- package/dist/harness/index.js +87 -0
- package/dist/harness/manager.js +182 -0
- package/dist/harness/models.js +8 -0
- package/dist/harness/recorder.js +89 -0
- package/dist/harness/recording-file.js +25 -0
- package/dist/harness/screen.js +79 -0
- package/dist/harness-capture-file.js +28 -0
- package/dist/harness-manager.js +112 -0
- package/dist/harness-models.js +8 -0
- package/dist/harness-screen.js +72 -0
- package/dist/harness.js +54 -0
- package/dist/index.js +163 -0
- package/dist/instance-lock.js +34 -0
- package/dist/interactive.js +33 -0
- package/dist/main.js +203 -0
- package/dist/managers.js +1 -0
- package/dist/message-handler.js +134 -0
- package/dist/messaging.js +47 -0
- package/dist/monitor/acp.js +24 -0
- package/dist/monitor/ask.js +31 -0
- package/dist/monitor/context.js +27 -0
- package/dist/monitor/editor-feed.js +49 -0
- package/dist/monitor/feed-diff.js +22 -0
- package/dist/monitor/feeds.js +19 -0
- package/dist/monitor/framing.js +29 -0
- package/dist/monitor/harness-feed.js +21 -0
- package/dist/monitor/info.js +29 -0
- package/dist/monitor/manager.js +221 -0
- package/dist/monitor/page-feed.js +21 -0
- package/dist/monitor/parsing.js +82 -0
- package/dist/monitor/reply.js +11 -0
- package/dist/monitor/session.js +36 -0
- package/dist/monitor/stop.js +33 -0
- package/dist/monitor/suggestion.js +12 -0
- package/dist/monitor/targets.js +69 -0
- package/dist/monitor/window.js +109 -0
- package/dist/monitor-acp.js +22 -0
- package/dist/monitor-info.js +18 -0
- package/dist/monitor-manager.js +233 -0
- package/dist/monitor-parsing.js +68 -0
- package/dist/monitor-session.js +19 -0
- package/dist/monitor-targets.js +37 -0
- package/dist/monitor-window.js +81 -0
- package/dist/notifications-tab.js +32 -0
- package/dist/notifications.js +87 -0
- package/dist/open-file-manager.js +100 -0
- package/dist/openers/editor.js +41 -0
- package/dist/openers/external-viewer.js +11 -0
- package/dist/openers/image.js +27 -0
- package/dist/openers/index.js +16 -0
- package/dist/openers/markdown.js +20 -0
- package/dist/openers/os-open.js +24 -0
- package/dist/openers/page.js +51 -0
- package/dist/openers/size.js +13 -0
- package/dist/openers/types.js +1 -0
- package/dist/page/sync.js +10 -0
- package/dist/paths.js +45 -0
- package/dist/persona-parsing.js +49 -0
- package/dist/personas.js +44 -0
- package/dist/profile/agent-opener.js +154 -0
- package/dist/profile/files.js +17 -0
- package/dist/profile/manager.js +72 -0
- package/dist/profile/monitors.js +23 -0
- package/dist/profile/notifications.js +11 -0
- package/dist/profile-agent-opener.js +136 -0
- package/dist/profile-manager.js +72 -0
- package/dist/profiles.js +143 -0
- package/dist/protocol.js +1 -0
- package/dist/pseudoterminal-manager.js +106 -0
- package/dist/pty.js +41 -0
- package/dist/recognizers/acp.js +38 -0
- package/dist/recognizers/analyze.js +49 -0
- package/dist/recognizers/bash.js +51 -0
- package/dist/recognizers/db.js +26 -0
- package/dist/recognizers/index.js +1 -0
- package/dist/recognizers/lexicon.js +11 -0
- package/dist/recognizers/types.js +5 -0
- package/dist/resolve.js +39 -0
- package/dist/route-choice.js +11 -0
- package/dist/sandbox/index.js +217 -0
- package/dist/sandbox/paths.js +178 -0
- package/dist/sandbox/profile.js +161 -0
- package/dist/sandbox-paths.js +159 -0
- package/dist/sandbox-profile.js +161 -0
- package/dist/sandbox.js +217 -0
- package/dist/schedule/display.js +19 -0
- package/dist/schedule/helpers.js +76 -0
- package/dist/schedule/index.js +76 -0
- package/dist/schedule/manager.js +101 -0
- package/dist/schedule/parsing.js +63 -0
- package/dist/schedule/time.js +34 -0
- package/dist/schedule-display.js +19 -0
- package/dist/schedule-helpers.js +76 -0
- package/dist/schedule-manager.js +98 -0
- package/dist/schedule-parsing.js +63 -0
- package/dist/schedule-time.js +34 -0
- package/dist/schedule.js +76 -0
- package/dist/search-matches.js +40 -0
- package/dist/security.js +44 -0
- package/dist/shell-manager.js +139 -0
- package/dist/shell.js +59 -0
- package/dist/ssh-manager.js +42 -0
- package/dist/ssh.js +39 -0
- package/dist/startup-errors.js +21 -0
- package/dist/state-event.js +14 -0
- package/dist/state-format.js +38 -0
- package/dist/syntax-themes.js +14 -0
- package/dist/tab/agent-state.js +19 -0
- package/dist/tab/cleanup.js +33 -0
- package/dist/tab/colors.js +43 -0
- package/dist/tab/creators.js +95 -0
- package/dist/tab/dock.js +35 -0
- package/dist/tab/focus-history.js +41 -0
- package/dist/tab/formatting-handlers.js +77 -0
- package/dist/tab/formatting.js +21 -0
- package/dist/tab/history.js +14 -0
- package/dist/tab/index.js +65 -0
- package/dist/tab/manager.js +329 -0
- package/dist/tab/openers.js +35 -0
- package/dist/tab/queue.js +33 -0
- package/dist/tab/rehydrate.js +15 -0
- package/dist/tab/reorder.js +15 -0
- package/dist/tab/transcript.js +15 -0
- package/dist/tab/utils.js +55 -0
- package/dist/tab/view.js +42 -0
- package/dist/tab-cleanup.js +27 -0
- package/dist/tab-colors.js +43 -0
- package/dist/tab-creators.js +85 -0
- package/dist/tab-formatting-handlers.js +77 -0
- package/dist/tab-formatting.js +21 -0
- package/dist/tab-manager.js +418 -0
- package/dist/tab-utils.js +55 -0
- package/dist/tab.js +57 -0
- package/dist/tasks.js +32 -0
- package/dist/transcript/logger.js +36 -0
- package/dist/transcript/store.js +66 -0
- package/dist/types.js +6 -0
- package/dist/user-agent.js +65 -0
- package/dist/word-wrapping.js +42 -0
- package/dist/workspace-manager.js +40 -0
- package/dist/workspace.js +120 -0
- package/help.md +97 -0
- package/package.json +138 -0
package/dist/sandbox.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { SANDBOX_PROFILE, SANDBOX_PROFILE_OFFLINE } from './sandbox-profile.js';
|
|
6
|
+
import { HOME_WRITE_CARVEOUTS, HOME_READ_CARVEINS, SECRET_DENY_PATHS, HOME_READ_LISTING_DIRS, HOME_WRITE_PREFIX_CARVEOUTS, WRITE_CARVEOUT_PARAMS, READ_CARVEIN_PARAMS, SECRET_DENY_PARAMS, LISTING_DIR_PARAMS, WRITE_PREFIX_PARAMS, ENV_SCRUB_PATTERNS, } from './sandbox-paths.js';
|
|
7
|
+
import { getConfig } from './config.js';
|
|
8
|
+
let cachedAvailable;
|
|
9
|
+
// Whether Seatbelt sandboxing can be applied on this machine: darwin, with `sandbox-exec` on
|
|
10
|
+
// `PATH`. Cached after the first check.
|
|
11
|
+
export function sandboxAvailable() {
|
|
12
|
+
cachedAvailable ??= process.platform === 'darwin' && existsSync('/usr/bin/sandbox-exec');
|
|
13
|
+
return cachedAvailable;
|
|
14
|
+
}
|
|
15
|
+
// A one-line notice to append to a newly created workspaced tab's transcript when its processes
|
|
16
|
+
// will NOT actually be confined — either the config toggle is off, or `sandbox-exec` isn't
|
|
17
|
+
// available on this machine. Undefined when sandboxing is active, so callers only surface it when
|
|
18
|
+
// there's something to say.
|
|
19
|
+
export function sandboxNotice() {
|
|
20
|
+
if (!getConfig().sandboxWorkspaces)
|
|
21
|
+
return 'workspace isolation off: sandboxWorkspaces disabled in config';
|
|
22
|
+
if (!sandboxAvailable())
|
|
23
|
+
return 'workspace isolation off: sandbox-exec unavailable';
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
// Resolve a path through any symlinks (macOS's `/tmp` → `/private/tmp` being the common case) —
|
|
27
|
+
// Seatbelt's `subpath` rules match against the resolved path, so an unresolved path silently
|
|
28
|
+
// fails to carve in. Falls back to the input path if it doesn't exist (yet).
|
|
29
|
+
function resolvePath(p) {
|
|
30
|
+
try {
|
|
31
|
+
return realpathSync(p);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return p;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
let cachedDarwinUserCacheDir;
|
|
38
|
+
// The real per-user `/var/folders/<xx>/<hash>/C/` cache directory macOS's `confstr(3)` hands out —
|
|
39
|
+
// NOT the same as `$TMPDIR`, which `sandboxSpawn` overrides to a workspace-local path below, and
|
|
40
|
+
// NOT its `.../T/` (temp) sibling, which stays denied (that's where `os.tmpdir()`-based scratch
|
|
41
|
+
// dirs land — carving it in too would let a sandboxed process write anywhere a plain `mktemp`
|
|
42
|
+
// call resolves to, defeating the outside-the-workspace write deny). System frameworks look the
|
|
43
|
+
// cache path up directly via `confstr`, bypassing our `TMPDIR` override entirely, and write
|
|
44
|
+
// lock/cache files into it regardless (e.g. Security.framework's legacy MDS subsystem locks
|
|
45
|
+
// `.../C/mds/mds.lock` on every `SecItemCopyMatching` call — denied, the call silently fails
|
|
46
|
+
// rather than erroring, so a sandboxed harness reads back "not logged in" even with a valid
|
|
47
|
+
// Keychain item). Cached: it's fixed for the life of the host process.
|
|
48
|
+
function darwinUserCacheDir() {
|
|
49
|
+
if (cachedDarwinUserCacheDir)
|
|
50
|
+
return cachedDarwinUserCacheDir;
|
|
51
|
+
try {
|
|
52
|
+
const cacheDir = execFileSync('getconf', ['DARWIN_USER_CACHE_DIR']).toString().trim();
|
|
53
|
+
cachedDarwinUserCacheDir = resolvePath(cacheDir);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
cachedDarwinUserCacheDir = '/nonexistent-janissary-darwin-user-cache-dir-placeholder';
|
|
57
|
+
}
|
|
58
|
+
return cachedDarwinUserCacheDir;
|
|
59
|
+
}
|
|
60
|
+
let cachedClaudeScratchDir;
|
|
61
|
+
// The harness CLI's own per-user scratch tree, `/private/tmp/claude-<uid>/`, under which it creates
|
|
62
|
+
// a per-project/session scratchpad directory before running any tool — including a plain shell
|
|
63
|
+
// command. This is a fixed, UID-keyed path entirely outside `$HOME`, our workspace-local `TMPDIR`
|
|
64
|
+
// override, and the Darwin user cache dir above, so without carving it in, every tool invocation
|
|
65
|
+
// inside a sandboxed harness session fails at that housekeeping step, before the tool's own command
|
|
66
|
+
// ever runs. Cached: the UID is fixed for the life of the host process.
|
|
67
|
+
function claudeScratchDir() {
|
|
68
|
+
if (cachedClaudeScratchDir)
|
|
69
|
+
return cachedClaudeScratchDir;
|
|
70
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : 0;
|
|
71
|
+
cachedClaudeScratchDir = resolvePath(`/private/tmp/claude-${uid}`);
|
|
72
|
+
return cachedClaudeScratchDir;
|
|
73
|
+
}
|
|
74
|
+
// Walk up from a resolved executable's path to the nearest ancestor directory literally named
|
|
75
|
+
// `node_modules`, if any. A globally-installed npm package (`npm` itself included) is laid out as
|
|
76
|
+
// `.../node_modules/<pkg>/bin/<script>`, where the script requires sibling files from `<pkg>/lib/`
|
|
77
|
+
// and beyond — carving in only the immediate `bin/` directory (as for a single bundled binary like
|
|
78
|
+
// claude.exe) leaves those sibling requires denied. Carving in the whole `node_modules/` directory
|
|
79
|
+
// instead covers every globally-installed package uniformly (not just the one being run), which is
|
|
80
|
+
// harmless — it's still just $HOME-scoped code, not secrets. Falls back to the script's own
|
|
81
|
+
// directory when there's no `node_modules` ancestor (a single-file bundled binary).
|
|
82
|
+
function packageRootDir(resolvedBin) {
|
|
83
|
+
let dir = path.dirname(resolvedBin);
|
|
84
|
+
for (;;) {
|
|
85
|
+
if (path.basename(dir) === 'node_modules')
|
|
86
|
+
return dir;
|
|
87
|
+
const parent = path.dirname(dir);
|
|
88
|
+
if (parent === dir)
|
|
89
|
+
return path.dirname(resolvedBin);
|
|
90
|
+
dir = parent;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// Resolve `command` to an absolute path the way `execvp`/`posix_spawn` would (a `PATH` search for a
|
|
94
|
+
// bare name, or the path itself if it already contains a separator) so its containing directory can
|
|
95
|
+
// be carved into the read allow-list. Without this, a harness binary installed under `$HOME` (e.g.
|
|
96
|
+
// via nvm, or `~/.opencode/bin`) can't read its own executable — which some system frameworks need
|
|
97
|
+
// to do internally (Keychain's `SecItemCopyMatching` calls `CFBundleGetMainBundle`, which reopens the
|
|
98
|
+
// calling process's own binary and its directory to determine code identity for ACL matching; denied,
|
|
99
|
+
// the harness looks logged out even though its Keychain item is intact). Returns both the literal
|
|
100
|
+
// (unresolved — e.g. many npm-global installs are a `bin/foo` symlink into `lib/node_modules/...`)
|
|
101
|
+
// and fully realpath-resolved directory (widened to the enclosing `node_modules/` via
|
|
102
|
+
// `packageRootDir` above, for packages — like npm — that are more than a single bundled file), same
|
|
103
|
+
// dual reasoning as `dualParams` in sandbox-profile.ts: a framework may `opendir`/`lstat` the
|
|
104
|
+
// symlink's own directory as well as the resolved target's. Falls back to a path that matches
|
|
105
|
+
// nothing so the profile's params always have a bound value.
|
|
106
|
+
function resolveExecutableDirs(command) {
|
|
107
|
+
const fallback = '/nonexistent-janissary-self-bin-placeholder';
|
|
108
|
+
const literalBin = command.includes('/')
|
|
109
|
+
? (existsSync(command) ? command : undefined)
|
|
110
|
+
: (process.env.PATH ?? '').split(':').filter(Boolean)
|
|
111
|
+
.map((dir) => path.join(dir, command))
|
|
112
|
+
.find((candidate) => existsSync(candidate));
|
|
113
|
+
if (!literalBin)
|
|
114
|
+
return { literal: fallback, real: fallback };
|
|
115
|
+
return { literal: path.dirname(literalBin), real: packageRootDir(resolvePath(literalBin)) };
|
|
116
|
+
}
|
|
117
|
+
// The directory of the Node binary currently running the janissary server itself
|
|
118
|
+
// (`process.execPath`), in both literal and realpath-resolved form (same dual reasoning as
|
|
119
|
+
// `resolveExecutableDirs` above — an nvm-managed `node` is commonly a symlink). Carved into the
|
|
120
|
+
// read allow-list so a script running inside the sandbox (e.g. a project's own `.claude/settings.json`
|
|
121
|
+
// hook) can reliably invoke a known-good `node` via the `JANISSARY_NODE` env var below, instead of
|
|
122
|
+
// hoping a bare `node` on the sandboxed process's PATH resolves to a working binary — PATH
|
|
123
|
+
// resolution order inside a spawned/hook context doesn't always match the server's own.
|
|
124
|
+
function serverNodeDirs() {
|
|
125
|
+
const execPath = process.execPath;
|
|
126
|
+
return { literal: path.dirname(execPath), real: path.dirname(resolvePath(execPath)) };
|
|
127
|
+
}
|
|
128
|
+
// The workspace clone's own git objects directory. Falls back to a parent repo's real objects
|
|
129
|
+
// directory via a `--shared` alternates file, if one is present (older, locally-shared clones) —
|
|
130
|
+
// today's independent clones of `origin` have no alternates file, so this always hits the fallback.
|
|
131
|
+
function parentGitObjectsDir(workspaceDir) {
|
|
132
|
+
const fallback = path.join(workspaceDir, '.git', 'objects');
|
|
133
|
+
try {
|
|
134
|
+
const alternatesPath = path.join(workspaceDir, '.git', 'objects', 'info', 'alternates');
|
|
135
|
+
const target = readFileSync(alternatesPath, 'utf8').trim().split('\n', 1)[0];
|
|
136
|
+
return target ? resolvePath(target) : fallback;
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return fallback;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// Drop credential-shaped vars and agent-socket escape vectors (see `sandbox-profile.ts`).
|
|
143
|
+
function scrubEnv(env) {
|
|
144
|
+
const scrubbed = {};
|
|
145
|
+
for (const [key, value] of Object.entries(env)) {
|
|
146
|
+
if (ENV_SCRUB_PATTERNS.some((pattern) => pattern.test(key)))
|
|
147
|
+
continue;
|
|
148
|
+
scrubbed[key] = value;
|
|
149
|
+
}
|
|
150
|
+
return scrubbed;
|
|
151
|
+
}
|
|
152
|
+
// `-D <param>=<path>` for each home-relative table entry, in both its literal (`~/…`, as named —
|
|
153
|
+
// covers an `lstat`/`readlink` of a symlinked dotfile) and fully realpath-resolved (covers a
|
|
154
|
+
// `read`/`open` that follows the symlink) forms. See the comment on `dualParams` in
|
|
155
|
+
// sandbox-profile.ts for why both are needed.
|
|
156
|
+
function homeDParams(home, relPaths, params) {
|
|
157
|
+
const args = [];
|
|
158
|
+
for (const [i, rel] of relPaths.entries()) {
|
|
159
|
+
const literalPath = path.join(home, rel);
|
|
160
|
+
args.push('-D', `${params.literal[i]}=${literalPath}`, '-D', `${params.real[i]}=${resolvePath(literalPath)}`);
|
|
161
|
+
}
|
|
162
|
+
return args;
|
|
163
|
+
}
|
|
164
|
+
// Wrap a spawn invocation (`command` + `args` — the same shape `child_process.spawn`/node-pty's
|
|
165
|
+
// `spawn` take) for a workspaced tab. Returns the input unchanged when there's nothing to
|
|
166
|
+
// sandbox: no `workspaceDir`, the `sandboxWorkspaces` config toggle is off, or `sandbox-exec`
|
|
167
|
+
// isn't available (e.g. non-darwin). Otherwise returns `sandbox-exec -p <profile> -D … -- <command>
|
|
168
|
+
// <args>` plus a credential-scrubbed environment with `TMPDIR` set to the workspace's private
|
|
169
|
+
// temp dir.
|
|
170
|
+
export function sandboxSpawn(options, command, args, env = process.env) {
|
|
171
|
+
if (!options.workspaceDir || !getConfig().sandboxWorkspaces || !sandboxAvailable()) {
|
|
172
|
+
return { command, args, env };
|
|
173
|
+
}
|
|
174
|
+
const workspaceDir = resolvePath(options.workspaceDir);
|
|
175
|
+
const tmpDir = resolvePath(`${options.workspaceDir}.tmp`);
|
|
176
|
+
const home = resolvePath(homedir());
|
|
177
|
+
const gitObjects = parentGitObjectsDir(options.workspaceDir);
|
|
178
|
+
const selfDirs = resolveExecutableDirs(options.selfBinaryHint ?? command);
|
|
179
|
+
const darwinCacheDir = darwinUserCacheDir();
|
|
180
|
+
const scratchDir = claudeScratchDir();
|
|
181
|
+
const serverNodeDir = serverNodeDirs();
|
|
182
|
+
const scrubbed = scrubEnv(env);
|
|
183
|
+
scrubbed.TMPDIR = tmpDir;
|
|
184
|
+
scrubbed.JANISSARY_NODE = process.execPath;
|
|
185
|
+
if (options.githubToken) {
|
|
186
|
+
scrubbed.GH_TOKEN = options.githubToken;
|
|
187
|
+
// `gh` reads `~/.config/gh/hosts.yml` on every invocation regardless of `GH_TOKEN`, and its
|
|
188
|
+
// config loader treats the sandbox's EPERM deny on that file (see SECRET_DENY_PATHS in
|
|
189
|
+
// sandbox-profile.ts) as fatal, refusing to run at all. Pointing `GH_CONFIG_DIR` at an empty,
|
|
190
|
+
// workspace-private directory instead gives `gh` a genuinely absent hosts.yml (real ENOENT),
|
|
191
|
+
// which it handles by falling through to `GH_TOKEN` normally.
|
|
192
|
+
scrubbed.GH_CONFIG_DIR = path.join(tmpDir, 'gh-config');
|
|
193
|
+
}
|
|
194
|
+
const profile = options.offline ? SANDBOX_PROFILE_OFFLINE : SANDBOX_PROFILE;
|
|
195
|
+
const dParams = [
|
|
196
|
+
'-D', `WORKSPACE=${workspaceDir}`,
|
|
197
|
+
'-D', `TMPDIR=${tmpDir}`,
|
|
198
|
+
'-D', `HOME=${home}`,
|
|
199
|
+
'-D', `GIT_OBJECTS=${gitObjects}`,
|
|
200
|
+
'-D', `SELF_DIR_L=${selfDirs.literal}`,
|
|
201
|
+
'-D', `SELF_DIR_R=${selfDirs.real}`,
|
|
202
|
+
'-D', `DARWIN_USER_CACHE_DIR=${darwinCacheDir}`,
|
|
203
|
+
'-D', `CLAUDE_SCRATCH_DIR=${scratchDir}`,
|
|
204
|
+
'-D', `SERVER_NODE_DIR_L=${serverNodeDir.literal}`,
|
|
205
|
+
'-D', `SERVER_NODE_DIR_R=${serverNodeDir.real}`,
|
|
206
|
+
...homeDParams(home, HOME_WRITE_CARVEOUTS, WRITE_CARVEOUT_PARAMS),
|
|
207
|
+
...homeDParams(home, HOME_READ_CARVEINS, READ_CARVEIN_PARAMS),
|
|
208
|
+
...homeDParams(home, SECRET_DENY_PATHS, SECRET_DENY_PARAMS),
|
|
209
|
+
...homeDParams(home, HOME_READ_LISTING_DIRS, LISTING_DIR_PARAMS),
|
|
210
|
+
...homeDParams(home, HOME_WRITE_PREFIX_CARVEOUTS, WRITE_PREFIX_PARAMS),
|
|
211
|
+
];
|
|
212
|
+
return {
|
|
213
|
+
command: 'sandbox-exec',
|
|
214
|
+
args: ['-p', profile, ...dParams, '--', command, ...args],
|
|
215
|
+
env: scrubbed,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { MONTHS } from './parsing.js';
|
|
2
|
+
export function fmtTime({ hour, minute }) {
|
|
3
|
+
const ap = hour < 12 ? 'am' : 'pm';
|
|
4
|
+
const h = hour % 12 === 0 ? 12 : hour % 12;
|
|
5
|
+
return `${h}:${String(minute).padStart(2, '0')}${ap}`;
|
|
6
|
+
}
|
|
7
|
+
export function fmtNextRun(ts) {
|
|
8
|
+
const d = new Date(ts);
|
|
9
|
+
const mon = MONTHS[d.getMonth()];
|
|
10
|
+
const month = mon[0].toUpperCase() + mon.slice(1, 3);
|
|
11
|
+
return `${month} ${d.getDate()} ${fmtTime({ hour: d.getHours(), minute: d.getMinutes() })}`;
|
|
12
|
+
}
|
|
13
|
+
export function formatSchedule(entries) {
|
|
14
|
+
if (entries.length === 0)
|
|
15
|
+
return 'No scheduled commands.';
|
|
16
|
+
return entries
|
|
17
|
+
.map((e) => `${e.id} ${e.spec} (next: ${fmtNextRun(e.nextRun)}) ${e.command}`)
|
|
18
|
+
.join('\n');
|
|
19
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
const MONTHS = [
|
|
2
|
+
'january', 'february', 'march', 'april', 'may', 'june',
|
|
3
|
+
'july', 'august', 'september', 'october', 'november', 'december',
|
|
4
|
+
];
|
|
5
|
+
const WEEKDAYS = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
|
|
6
|
+
const SCHEDULE_USAGE = 'Usage: schedule NAME <at TIME | on DATE [at TIME] | every N(m|h|d|w) | every DAY at TIME> COMMAND | schedule list | schedule cancel <name> | schedule clear';
|
|
7
|
+
export function parseAtSchedule(tokens, now, parseTimeOfDay, fmtTime, nextOccurrenceOfTime) {
|
|
8
|
+
const tod = parseTimeOfDay(tokens[1] ?? '');
|
|
9
|
+
if (!tod)
|
|
10
|
+
return { error: `Invalid time: "${tokens[1] ?? ''}".` };
|
|
11
|
+
const command = tokens.slice(2).join(' ').trim();
|
|
12
|
+
if (!command)
|
|
13
|
+
return { error: 'No command to schedule.' };
|
|
14
|
+
return { action: 'add', entry: {
|
|
15
|
+
command, spec: `at ${fmtTime(tod)}`, recurring: false,
|
|
16
|
+
nextRun: nextOccurrenceOfTime(tod.hour, tod.minute, now),
|
|
17
|
+
} };
|
|
18
|
+
}
|
|
19
|
+
export function parseOnSchedule(tokens, now, parseMonthDay, parseTimeOfDay, fmtTime, nextDateTime) {
|
|
20
|
+
const md = parseMonthDay(tokens.slice(1));
|
|
21
|
+
if (!md)
|
|
22
|
+
return { error: 'Invalid date. Try "on august 12th" or "on 8/12".' };
|
|
23
|
+
let index = 1 + md.consumed;
|
|
24
|
+
let tod = { hour: 9, minute: 0 };
|
|
25
|
+
if (tokens[index]?.toLowerCase() === 'at') {
|
|
26
|
+
const t = parseTimeOfDay(tokens[index + 1] ?? '');
|
|
27
|
+
if (!t)
|
|
28
|
+
return { error: `Invalid time: "${tokens[index + 1] ?? ''}".` };
|
|
29
|
+
tod = t;
|
|
30
|
+
index += 2;
|
|
31
|
+
}
|
|
32
|
+
const command = tokens.slice(index).join(' ').trim();
|
|
33
|
+
if (!command)
|
|
34
|
+
return { error: 'No command to schedule.' };
|
|
35
|
+
return { action: 'add', entry: {
|
|
36
|
+
command, spec: `on ${MONTHS[md.month].slice(0, 3)} ${md.day} at ${fmtTime(tod)}`, recurring: false,
|
|
37
|
+
nextRun: nextDateTime(md.month, md.day, tod.hour, tod.minute, now),
|
|
38
|
+
} };
|
|
39
|
+
}
|
|
40
|
+
export function parseEverySchedule(tokens, now, parseInterval, parseTimeOfDay, fmtTime, nextOccurrenceOfTime, nextWeekday) {
|
|
41
|
+
const second = tokens[1] ?? '';
|
|
42
|
+
const interval = parseInterval(second);
|
|
43
|
+
if (interval !== undefined) {
|
|
44
|
+
const command = tokens.slice(2).join(' ').trim();
|
|
45
|
+
if (!command)
|
|
46
|
+
return { error: 'No command to schedule.' };
|
|
47
|
+
return { action: 'add', entry: {
|
|
48
|
+
command, spec: `every ${second.toLowerCase()}`, recurring: true,
|
|
49
|
+
intervalMs: interval, nextRun: now.getTime() + interval,
|
|
50
|
+
} };
|
|
51
|
+
}
|
|
52
|
+
const dayWord = second.toLowerCase();
|
|
53
|
+
let weekday;
|
|
54
|
+
if (dayWord !== 'day') {
|
|
55
|
+
const wd = dayWord.length >= 3 ? WEEKDAYS.findIndex((w) => w.startsWith(dayWord)) : -1;
|
|
56
|
+
if (wd < 0)
|
|
57
|
+
return { error: `Invalid interval or day: "${second}".` };
|
|
58
|
+
weekday = wd;
|
|
59
|
+
}
|
|
60
|
+
if (tokens[2]?.toLowerCase() !== 'at')
|
|
61
|
+
return { error: SCHEDULE_USAGE };
|
|
62
|
+
const tod = parseTimeOfDay(tokens[3] ?? '');
|
|
63
|
+
if (!tod)
|
|
64
|
+
return { error: `Invalid time: "${tokens[3] ?? ''}".` };
|
|
65
|
+
const command = tokens.slice(4).join(' ').trim();
|
|
66
|
+
if (!command)
|
|
67
|
+
return { error: 'No command to schedule.' };
|
|
68
|
+
const label = weekday === undefined ? 'day' : WEEKDAYS[weekday];
|
|
69
|
+
return { action: 'add', entry: {
|
|
70
|
+
command, spec: `every ${label} at ${fmtTime(tod)}`, recurring: true,
|
|
71
|
+
timeOfDay: tod, weekday,
|
|
72
|
+
nextRun: weekday === undefined
|
|
73
|
+
? nextOccurrenceOfTime(tod.hour, tod.minute, now)
|
|
74
|
+
: nextWeekday(weekday, tod.hour, tod.minute, now),
|
|
75
|
+
} };
|
|
76
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Parsing and next-run math for the `schedule` command. Pure (no I/O) so it is fully
|
|
2
|
+
// unit-testable; callers (the command + the scheduler tick) own the side effects.
|
|
3
|
+
import { parseAtSchedule, parseOnSchedule, parseEverySchedule } from './helpers.js';
|
|
4
|
+
import { parseTimeOfDay, parseInterval, parseMonthDay } from './parsing.js';
|
|
5
|
+
export { parseTimeOfDay, parseInterval, parseMonthDay } from './parsing.js';
|
|
6
|
+
import { nextOccurrenceOfTime, nextWeekday, nextDateTime } from './time.js';
|
|
7
|
+
export { nextOccurrenceOfTime, nextWeekday, computeNextRun } from './time.js';
|
|
8
|
+
import { fmtTime } from './display.js';
|
|
9
|
+
export { fmtNextRun, formatSchedule } from './display.js';
|
|
10
|
+
export const SCHEDULE_USAGE = 'Usage: schedule NAME [in TAB] <at TIME | on DATE [at TIME] | every N(m|h|d|w) | every DAY at TIME> COMMAND'
|
|
11
|
+
+ ' | schedule list [in TAB] | schedule cancel <name> [in TAB] | schedule clear [in TAB]';
|
|
12
|
+
// Parse a trailing `in <tab>` clause starting at `tokens[index]`. Returns the target label,
|
|
13
|
+
// an empty object when the clause is absent, or an error when it is malformed or followed
|
|
14
|
+
// by extra tokens (the clause must end the command).
|
|
15
|
+
function parseInClause(tokens, index) {
|
|
16
|
+
const [keyword, target, ...extra] = tokens.slice(index);
|
|
17
|
+
if (keyword === undefined)
|
|
18
|
+
return {};
|
|
19
|
+
if (keyword.toLowerCase() !== 'in' || target === undefined || extra.length > 0)
|
|
20
|
+
return { error: SCHEDULE_USAGE };
|
|
21
|
+
return { target };
|
|
22
|
+
}
|
|
23
|
+
export function parseScheduleCommand(rest, now) {
|
|
24
|
+
const trimmed = rest.trim();
|
|
25
|
+
if (!trimmed)
|
|
26
|
+
return { error: SCHEDULE_USAGE };
|
|
27
|
+
const tokens = trimmed.split(/\s+/);
|
|
28
|
+
const head = tokens[0].toLowerCase();
|
|
29
|
+
if (head === 'list' || head === 'clear') {
|
|
30
|
+
const clause = parseInClause(tokens, 1);
|
|
31
|
+
if ('error' in clause)
|
|
32
|
+
return clause;
|
|
33
|
+
return { action: head, ...clause };
|
|
34
|
+
}
|
|
35
|
+
if (head === 'cancel') {
|
|
36
|
+
if (!tokens[1])
|
|
37
|
+
return { error: 'Usage: schedule cancel <name> [in TAB]' };
|
|
38
|
+
const clause = parseInClause(tokens, 2);
|
|
39
|
+
if ('error' in clause)
|
|
40
|
+
return clause;
|
|
41
|
+
return { action: 'cancel', id: tokens[1], ...clause };
|
|
42
|
+
}
|
|
43
|
+
// Otherwise the first token names the timer (becoming its id, shown in the schedule window
|
|
44
|
+
// and used by `schedule cancel <name>`), an optional `in <tab>` picks the tab the timer
|
|
45
|
+
// belongs to, and the remainder is the schedule form.
|
|
46
|
+
const name = tokens[0];
|
|
47
|
+
let bodyTokens = tokens.slice(1);
|
|
48
|
+
let target;
|
|
49
|
+
if (bodyTokens[0]?.toLowerCase() === 'in') {
|
|
50
|
+
if (!bodyTokens[1])
|
|
51
|
+
return { error: SCHEDULE_USAGE };
|
|
52
|
+
target = bodyTokens[1];
|
|
53
|
+
bodyTokens = bodyTokens.slice(2);
|
|
54
|
+
}
|
|
55
|
+
const body = parseScheduleBody(bodyTokens.join(' '), now);
|
|
56
|
+
if ('error' in body)
|
|
57
|
+
return body;
|
|
58
|
+
return { ...body, name, ...(target !== undefined && { target }) };
|
|
59
|
+
}
|
|
60
|
+
function parseScheduleBody(rest, now) {
|
|
61
|
+
const trimmed = rest.trim();
|
|
62
|
+
if (!trimmed)
|
|
63
|
+
return { error: SCHEDULE_USAGE };
|
|
64
|
+
const tokens = trimmed.split(/\s+/);
|
|
65
|
+
const head = tokens[0].toLowerCase();
|
|
66
|
+
if (head === 'at') {
|
|
67
|
+
return parseAtSchedule(tokens, now, parseTimeOfDay, fmtTime, nextOccurrenceOfTime);
|
|
68
|
+
}
|
|
69
|
+
if (head === 'on') {
|
|
70
|
+
return parseOnSchedule(tokens, now, parseMonthDay, parseTimeOfDay, fmtTime, nextDateTime);
|
|
71
|
+
}
|
|
72
|
+
if (head === 'every') {
|
|
73
|
+
return parseEverySchedule(tokens, now, parseInterval, parseTimeOfDay, fmtTime, nextOccurrenceOfTime, nextWeekday);
|
|
74
|
+
}
|
|
75
|
+
return { error: SCHEDULE_USAGE };
|
|
76
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { computeNextRun, fmtNextRun } from './index.js';
|
|
2
|
+
import { messageBus } from '../bus.js';
|
|
3
|
+
import { notify } from '../notifications.js';
|
|
4
|
+
// Owns the per-tab scheduled commands (keyed by tab label) and the 1-second firing loop: at each tick
|
|
5
|
+
// it fires any entry whose next-run time has passed, reschedules recurring ones, and drops one-shots.
|
|
6
|
+
// The controller owns the tabs and persistence; this module owns the schedule state and timing.
|
|
7
|
+
export class ScheduleManager {
|
|
8
|
+
managers;
|
|
9
|
+
schedules = new Map();
|
|
10
|
+
timer;
|
|
11
|
+
constructor(managers) {
|
|
12
|
+
this.managers = managers;
|
|
13
|
+
}
|
|
14
|
+
// Begin the firing loop. `unref` so a pending tick never keeps the process alive on its own.
|
|
15
|
+
start() {
|
|
16
|
+
this.timer = setInterval(() => this.tick(), 1000);
|
|
17
|
+
this.timer.unref?.();
|
|
18
|
+
}
|
|
19
|
+
// Stop the firing loop (app shutdown).
|
|
20
|
+
stop() {
|
|
21
|
+
clearInterval(this.timer);
|
|
22
|
+
}
|
|
23
|
+
// A tab's scheduled commands, or undefined when it has none (raw — for persistence and the command
|
|
24
|
+
// context, both of which distinguish "no schedule" from "empty schedule").
|
|
25
|
+
get(label) {
|
|
26
|
+
return this.schedules.get(label);
|
|
27
|
+
}
|
|
28
|
+
// Replace a tab's scheduled commands. Persisting is the caller's concern (rehydrate/profile load
|
|
29
|
+
// restore without re-persisting; the `schedule` command persists separately).
|
|
30
|
+
set(label, entries) {
|
|
31
|
+
this.schedules.set(label, entries);
|
|
32
|
+
}
|
|
33
|
+
// Forget a tab's schedule (on tab close).
|
|
34
|
+
delete(label) {
|
|
35
|
+
this.schedules.delete(label);
|
|
36
|
+
}
|
|
37
|
+
// The schedule rows for a tab's view: id, spec, humanized next-run time, and the recurring flag.
|
|
38
|
+
view(label) {
|
|
39
|
+
return (this.schedules.get(label) ?? []).map((e) => ({
|
|
40
|
+
id: e.id, spec: e.spec, next: fmtNextRun(e.nextRun), recurring: e.recurring,
|
|
41
|
+
}));
|
|
42
|
+
}
|
|
43
|
+
// Fire any commands whose next-run time has passed, in every still-open tab. A recurring entry is
|
|
44
|
+
// rescheduled to its next run; a one-shot drops off. Tabs whose schedule changed are persisted
|
|
45
|
+
// (harness tabs excepted — they have no persisted agent state).
|
|
46
|
+
tick() {
|
|
47
|
+
const now = Date.now();
|
|
48
|
+
let changed = false;
|
|
49
|
+
for (const label of this.managers.tab.allLabels()) {
|
|
50
|
+
const tab = this.managers.tab.tabs.find((t) => t.label === label);
|
|
51
|
+
const sched = this.schedules.get(label);
|
|
52
|
+
if (!tab || !sched || sched.length === 0)
|
|
53
|
+
continue;
|
|
54
|
+
const remaining = this.fireDue(tab, sched, now);
|
|
55
|
+
if (!remaining)
|
|
56
|
+
continue;
|
|
57
|
+
this.schedules.set(label, remaining);
|
|
58
|
+
changed = true;
|
|
59
|
+
if (tab.view !== 'harness')
|
|
60
|
+
this.managers.tab.persist(this.managers.tab.buildAgentState(tab, { schedule: this.get(label) }));
|
|
61
|
+
}
|
|
62
|
+
if (changed)
|
|
63
|
+
messageBus.emit('state', { type: 'dirty' });
|
|
64
|
+
}
|
|
65
|
+
// Fire one tab's due entries, returning the surviving schedule (recurring entries rescheduled,
|
|
66
|
+
// one-shots dropped), or undefined when nothing fired.
|
|
67
|
+
fireDue(tab, sched, now) {
|
|
68
|
+
let isChanged = false;
|
|
69
|
+
const remaining = [];
|
|
70
|
+
for (const e of sched) {
|
|
71
|
+
if (e.nextRun > now || !this.fire(tab, e)) {
|
|
72
|
+
remaining.push(e);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
isChanged = true;
|
|
76
|
+
if (e.recurring)
|
|
77
|
+
remaining.push({ ...e, nextRun: computeNextRun(e, new Date()) });
|
|
78
|
+
}
|
|
79
|
+
return isChanged ? remaining : undefined;
|
|
80
|
+
}
|
|
81
|
+
// Deliver a due entry to its tab: typed into a harness PTY as a line of input, or dispatched
|
|
82
|
+
// through an agent tab's command pipeline. Returns false when delivery must wait (the harness
|
|
83
|
+
// is not running), leaving the entry due so it retries on a later tick.
|
|
84
|
+
fire(tab, e) {
|
|
85
|
+
if (tab.view === 'harness') {
|
|
86
|
+
if (tab.harness?.status !== 'running' || !tab.harness.ptyId)
|
|
87
|
+
return false;
|
|
88
|
+
// Sent as one write, a long command's trailing \r can land inside the same burst the harness's
|
|
89
|
+
// own input parser treats as a paste, so it's read as inserted text rather than submit. Splitting
|
|
90
|
+
// the \r into its own write after the text has been processed mimics organic typing and avoids that.
|
|
91
|
+
const ptyId = tab.harness.ptyId;
|
|
92
|
+
this.managers.pty.input(ptyId, e.command);
|
|
93
|
+
setTimeout(() => this.managers.pty.input(ptyId, '\r'), 50);
|
|
94
|
+
notify(this.managers, 'schedule-fire', tab.label, e.command);
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
this.managers.command.dispatchTo(tab.label, `${e.command} ## scheduled ##`);
|
|
98
|
+
notify(this.managers, 'schedule-fire', tab.label, e.command);
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
const MONTHS = [
|
|
2
|
+
'january', 'february', 'march', 'april', 'may', 'june',
|
|
3
|
+
'july', 'august', 'september', 'october', 'november', 'december',
|
|
4
|
+
];
|
|
5
|
+
const UNIT_MS = { m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000 };
|
|
6
|
+
export { MONTHS };
|
|
7
|
+
export function parseTimeOfDay(tok) {
|
|
8
|
+
// Anchored at both ends; bounded quantifiers only — not a ReDoS risk.
|
|
9
|
+
// eslint-disable-next-line security/detect-unsafe-regex
|
|
10
|
+
const m = /^(\d{1,2})(?::(\d{2}))?\s*(am|pm)?$/i.exec(tok.trim());
|
|
11
|
+
if (!m)
|
|
12
|
+
return undefined;
|
|
13
|
+
let hour = Number(m[1]);
|
|
14
|
+
const minute = m[2] ? Number(m[2]) : 0;
|
|
15
|
+
const ap = m[3]?.toLowerCase();
|
|
16
|
+
if (ap) {
|
|
17
|
+
if (hour < 1 || hour > 12)
|
|
18
|
+
return undefined;
|
|
19
|
+
if (ap === 'pm' && hour !== 12)
|
|
20
|
+
hour += 12;
|
|
21
|
+
if (ap === 'am' && hour === 12)
|
|
22
|
+
hour = 0;
|
|
23
|
+
}
|
|
24
|
+
else if (hour > 23) {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
if (minute > 59)
|
|
28
|
+
return undefined;
|
|
29
|
+
return { hour, minute };
|
|
30
|
+
}
|
|
31
|
+
export function parseInterval(tok) {
|
|
32
|
+
const m = /^(\d+)(m|h|d|w)$/i.exec(tok.trim());
|
|
33
|
+
if (!m)
|
|
34
|
+
return undefined;
|
|
35
|
+
const n = Number(m[1]);
|
|
36
|
+
if (n <= 0)
|
|
37
|
+
return undefined;
|
|
38
|
+
return n * UNIT_MS[m[2].toLowerCase()];
|
|
39
|
+
}
|
|
40
|
+
export function parseMonthDay(tokens) {
|
|
41
|
+
const first = tokens[0];
|
|
42
|
+
if (!first)
|
|
43
|
+
return undefined;
|
|
44
|
+
const slash = /^(\d{1,2})\/(\d{1,2})$/.exec(first);
|
|
45
|
+
if (slash) {
|
|
46
|
+
const month = Number(slash[1]) - 1;
|
|
47
|
+
const day = Number(slash[2]);
|
|
48
|
+
if (month < 0 || month > 11 || day < 1 || day > 31)
|
|
49
|
+
return undefined;
|
|
50
|
+
return { month, day, consumed: 1 };
|
|
51
|
+
}
|
|
52
|
+
const lc = first.toLowerCase();
|
|
53
|
+
const month = lc.length >= 3 ? MONTHS.findIndex((m) => m.startsWith(lc)) : -1;
|
|
54
|
+
if (month < 0)
|
|
55
|
+
return undefined;
|
|
56
|
+
const dm = /^(\d{1,2})(?:st|nd|rd|th)?$/i.exec(tokens[1] ?? '');
|
|
57
|
+
if (!dm)
|
|
58
|
+
return undefined;
|
|
59
|
+
const day = Number(dm[1]);
|
|
60
|
+
if (day < 1 || day > 31)
|
|
61
|
+
return undefined;
|
|
62
|
+
return { month, day, consumed: 2 };
|
|
63
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export function nextOccurrenceOfTime(hour, minute, now) {
|
|
2
|
+
const d = new Date(now);
|
|
3
|
+
d.setHours(hour, minute, 0, 0);
|
|
4
|
+
if (d.getTime() <= now.getTime())
|
|
5
|
+
d.setDate(d.getDate() + 1);
|
|
6
|
+
return d.getTime();
|
|
7
|
+
}
|
|
8
|
+
export function nextWeekday(weekday, hour, minute, now) {
|
|
9
|
+
const d = new Date(now);
|
|
10
|
+
d.setHours(hour, minute, 0, 0);
|
|
11
|
+
let diff = (weekday - d.getDay() + 7) % 7;
|
|
12
|
+
if (diff === 0 && d.getTime() <= now.getTime())
|
|
13
|
+
diff = 7;
|
|
14
|
+
d.setDate(d.getDate() + diff);
|
|
15
|
+
return d.getTime();
|
|
16
|
+
}
|
|
17
|
+
export function nextDateTime(month, day, hour, minute, now) {
|
|
18
|
+
const year = now.getFullYear();
|
|
19
|
+
const d = new Date(year, month, day, hour, minute, 0, 0);
|
|
20
|
+
if (d.getTime() <= now.getTime())
|
|
21
|
+
d.setFullYear(year + 1);
|
|
22
|
+
return d.getTime();
|
|
23
|
+
}
|
|
24
|
+
export function computeNextRun(entry, now) {
|
|
25
|
+
if (entry.intervalMs)
|
|
26
|
+
return now.getTime() + entry.intervalMs;
|
|
27
|
+
if (entry.timeOfDay) {
|
|
28
|
+
const { hour, minute } = entry.timeOfDay;
|
|
29
|
+
return entry.weekday === undefined
|
|
30
|
+
? nextOccurrenceOfTime(hour, minute, now)
|
|
31
|
+
: nextWeekday(entry.weekday, hour, minute, now);
|
|
32
|
+
}
|
|
33
|
+
return now.getTime();
|
|
34
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { MONTHS } from './schedule-parsing.js';
|
|
2
|
+
export function fmtTime({ hour, minute }) {
|
|
3
|
+
const ap = hour < 12 ? 'am' : 'pm';
|
|
4
|
+
const h = hour % 12 === 0 ? 12 : hour % 12;
|
|
5
|
+
return `${h}:${String(minute).padStart(2, '0')}${ap}`;
|
|
6
|
+
}
|
|
7
|
+
export function fmtNextRun(ts) {
|
|
8
|
+
const d = new Date(ts);
|
|
9
|
+
const mon = MONTHS[d.getMonth()];
|
|
10
|
+
const month = mon[0].toUpperCase() + mon.slice(1, 3);
|
|
11
|
+
return `${month} ${d.getDate()} ${fmtTime({ hour: d.getHours(), minute: d.getMinutes() })}`;
|
|
12
|
+
}
|
|
13
|
+
export function formatSchedule(entries) {
|
|
14
|
+
if (entries.length === 0)
|
|
15
|
+
return 'No scheduled commands.';
|
|
16
|
+
return entries
|
|
17
|
+
.map((e) => `${e.id} ${e.spec} (next: ${fmtNextRun(e.nextRun)}) ${e.command}`)
|
|
18
|
+
.join('\n');
|
|
19
|
+
}
|