agentstreamdeck 2.1.1__py3-none-any.whl

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 (65) hide show
  1. agentstreamdeck-2.1.1.dist-info/METADATA +1013 -0
  2. agentstreamdeck-2.1.1.dist-info/RECORD +65 -0
  3. agentstreamdeck-2.1.1.dist-info/WHEEL +5 -0
  4. agentstreamdeck-2.1.1.dist-info/entry_points.txt +2 -0
  5. agentstreamdeck-2.1.1.dist-info/licenses/LICENSE +201 -0
  6. agentstreamdeck-2.1.1.dist-info/top_level.txt +1 -0
  7. ocdeck/__init__.py +1 -0
  8. ocdeck/__main__.py +307 -0
  9. ocdeck/alerts.py +120 -0
  10. ocdeck/appearance.py +133 -0
  11. ocdeck/appearance_io.py +70 -0
  12. ocdeck/art.py +181 -0
  13. ocdeck/assets/logos/OCTICONS-LICENSE.txt +21 -0
  14. ocdeck/assets/logos/claude.png +0 -0
  15. ocdeck/assets/logos/copilot.png +0 -0
  16. ocdeck/assets/logos/copilot.svg +1 -0
  17. ocdeck/assets/logos/cursor.png +0 -0
  18. ocdeck/assets/logos/gemini.png +0 -0
  19. ocdeck/assets/logos/opencode.png +0 -0
  20. ocdeck/assets/logos/sources.json +27 -0
  21. ocdeck/broker.py +273 -0
  22. ocdeck/common.py +82 -0
  23. ocdeck/device.py +247 -0
  24. ocdeck/diagnostics.py +227 -0
  25. ocdeck/errors.py +22 -0
  26. ocdeck/focus.py +156 -0
  27. ocdeck/hardware_check.py +66 -0
  28. ocdeck/harness.py +203 -0
  29. ocdeck/launcher.py +203 -0
  30. ocdeck/model.py +177 -0
  31. ocdeck/observability.py +57 -0
  32. ocdeck/runtime/plugins/core.mjs +122 -0
  33. ocdeck/runtime/plugins/harnesses/bridge.mjs +52 -0
  34. ocdeck/runtime/plugins/harnesses/hook.mjs +38 -0
  35. ocdeck/runtime/plugins/harnesses/install.mjs +83 -0
  36. ocdeck/runtime/plugins/harnesses/profiles.mjs +107 -0
  37. ocdeck/runtime/plugins/server.mjs +67 -0
  38. ocdeck/runtime/plugins/tui.mjs +44 -0
  39. ocdeck/runtime/scripts/Install-Harness.ps1 +20 -0
  40. ocdeck/runtime/scripts/Install.ps1 +79 -0
  41. ocdeck/runtime/scripts/Launch-Agent.bat +7 -0
  42. ocdeck/runtime/scripts/Launch-Claude.bat +7 -0
  43. ocdeck/runtime/scripts/Launch-Codex.bat +7 -0
  44. ocdeck/runtime/scripts/Launch-Copilot-VSCode.bat +7 -0
  45. ocdeck/runtime/scripts/Launch-Copilot.bat +7 -0
  46. ocdeck/runtime/scripts/Launch-Cursor.bat +7 -0
  47. ocdeck/runtime/scripts/Launch-Gemini.bat +7 -0
  48. ocdeck/runtime/scripts/Remove-Integration.ps1 +35 -0
  49. ocdeck/runtime/scripts/Run-OpenCode.ps1 +7 -0
  50. ocdeck/runtime/scripts/Test.ps1 +11 -0
  51. ocdeck/runtime/scripts/Uninstall.ps1 +11 -0
  52. ocdeck/runtime/scripts/Verify-Windows.ps1 +13 -0
  53. ocdeck/runtime/scripts/check-js.py +8 -0
  54. ocdeck/runtime/scripts/examples/Claude-Cloud.bat +6 -0
  55. ocdeck/runtime/scripts/examples/Claude-Local.bat +21 -0
  56. ocdeck/runtime/scripts/examples/HomeAILab-Claude-5090.bat +8 -0
  57. ocdeck/runtime/scripts/examples/HomeAILab-Claude-Cluster.bat +8 -0
  58. ocdeck/runtime/scripts/examples/HomeAILab-OpenCode-5090.bat +8 -0
  59. ocdeck/runtime/scripts/examples/HomeAILab-OpenCode-Spark.bat +8 -0
  60. ocdeck/runtime/scripts/examples/OpenCode-Cloud.bat +8 -0
  61. ocdeck/runtime/scripts/render-gallery.py +94 -0
  62. ocdeck/security.py +38 -0
  63. ocdeck/settings.py +42 -0
  64. ocdeck/uninstall.py +100 -0
  65. ocdeck/updates.py +46 -0
@@ -0,0 +1,83 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import {fileURLToPath} from 'node:url';
4
+ import {profiles} from './profiles.mjs';
5
+
6
+ export function configuration(profile, script = fileURLToPath(new URL('./hook.mjs', import.meta.url))) {
7
+ const p = profiles[profile];
8
+ if (!p) throw Error('Unknown harness: ' + profile);
9
+ // Double quotes work in Bash, cmd and PowerShell for these paths. Refuse shell metacharacters
10
+ // that could expand even inside quotes; never interpolate user prompts into commands.
11
+ script = script.replaceAll('\\', '/');
12
+ if (/["$`%!\r\n]/.test(script)) throw Error('Move AgentStreamDeck to a path without quotes, $, backticks, %, or !');
13
+ const hooks = {};
14
+ for (const event of Object.keys(p.events)) {
15
+ const command = `node "${script}" ${profile} ${event}`;
16
+ let entry = {type:'command', command, timeout:5};
17
+ if (p.format === 'copilot') entry = {type:'command', bash:command, powershell:command, timeoutSec:5};
18
+ if (p.format === 'cursor') entry = {command, timeout:5};
19
+ if (p.format === 'nested') {
20
+ // Gemini measures hook timeout in milliseconds; Claude measures seconds.
21
+ if (profile === 'gemini') entry.timeout = 5000;
22
+ if (profile === 'codex' && ['SessionEnd','Interrupt'].includes(event)) entry.timeout = 3;
23
+ entry = {hooks:[entry]};
24
+ }
25
+ hooks[event] = [entry];
26
+ }
27
+ return {...(['copilot','cursor'].includes(p.format) ? {version:1} : {}), hooks};
28
+ }
29
+ const same = (a,b) => JSON.stringify(a) === JSON.stringify(b);
30
+ export function mergeConfig(existing, generated, prior = null) {
31
+ const result = structuredClone(existing);
32
+ if (!result || typeof result !== 'object' || Array.isArray(result)) throw Error('Config must be an object');
33
+ result.hooks ??= {};
34
+ if (typeof result.hooks !== 'object' || Array.isArray(result.hooks) || result.hooks === null) throw Error('hooks must be an object');
35
+ if (generated.version && result.version !== undefined && result.version !== generated.version) throw Error('Unsupported config version');
36
+ if (generated.version) result.version = generated.version;
37
+ for (const [event, entries] of Object.entries(prior?.hooks || {})) {
38
+ if (result.hooks[event] !== undefined && !Array.isArray(result.hooks[event])) throw Error('Invalid hook array: ' + event);
39
+ if (result.hooks[event]) result.hooks[event] = result.hooks[event].filter(x => !entries.some(y => same(x,y)));
40
+ }
41
+ for (const [event, entries] of Object.entries(generated.hooks)) {
42
+ result.hooks[event] ??= [];
43
+ if (!Array.isArray(result.hooks[event])) throw Error('Invalid hook array: ' + event);
44
+ for (const entry of entries) if (!result.hooks[event].some(x => same(x,entry))) result.hooks[event].push(entry);
45
+ }
46
+ for (const event of Object.keys(result.hooks)) if (result.hooks[event].length === 0) delete result.hooks[event];
47
+ return result;
48
+ }
49
+ async function read(file, fallback) {
50
+ try { return JSON.parse((await fs.readFile(file,'utf8')).replace(/^\uFEFF/, '')); }
51
+ catch (e) { if (e.code === 'ENOENT') return fallback; throw Error(`Cannot parse ${file}; merge JSONC manually: ${e.message}`); }
52
+ }
53
+ async function atomic(file, value) {
54
+ await fs.mkdir(path.dirname(file), {recursive:true});
55
+ await fs.writeFile(file + '.agentdeck-tmp', JSON.stringify(value,null,2)+'\n', {flag:'wx'});
56
+ await fs.rename(file + '.agentdeck-tmp', file);
57
+ }
58
+ export async function install(profile, project, {remove=false, dryRun=false} = {}) {
59
+ if (!profiles[profile]) throw Error('Unknown harness');
60
+ const target = path.resolve(project, profiles[profile].config);
61
+ const manifest = path.resolve(project, '.agentdeck', profile + '.json');
62
+ const prior = await read(manifest, null);
63
+ if (prior && prior.target !== target) throw Error('Project moved; remove old hooks manually before reinstalling');
64
+ const existing = await read(target, {});
65
+ const generated = remove ? {hooks:{}} : configuration(profile);
66
+ const merged = mergeConfig(existing, generated, prior?.configuration);
67
+ if (dryRun) return {target, configuration:merged};
68
+ if (remove && !prior) throw Error('No AgentStreamDeck installation manifest; nothing removed');
69
+ if (!same(existing,merged)) {
70
+ try { await fs.copyFile(target, target + `.agentdeck-backup-${Date.now()}`, 1); }
71
+ catch (e) { if (e.code !== 'ENOENT') throw e; }
72
+ await atomic(target,merged);
73
+ }
74
+ if (remove) await fs.rm(manifest, {force:true});
75
+ else await atomic(manifest, {target, configuration:generated});
76
+ return {target, action:remove ? 'removed' : 'installed'};
77
+ }
78
+ if (process.argv[2] === '--cli') {
79
+ try {
80
+ const [profile, project, ...flags] = process.argv.slice(3);
81
+ console.log(JSON.stringify(await install(profile, project, {remove:flags.includes('--remove'), dryRun:flags.includes('--dry-run')}),null,2));
82
+ } catch (e) { console.error(e.message); process.exitCode = 1; }
83
+ }
@@ -0,0 +1,107 @@
1
+ import crypto from 'node:crypto';
2
+ // Native hook names stay here; transport and the broker remain harness-neutral.
3
+ export const profiles = {
4
+ codex: {
5
+ label:'Codex', executable:'codex', config:'.codex/hooks.json', format:'nested',
6
+ events:{SessionStart:'start', SessionEnd:'end', UserPromptSubmit:'busy', PreToolUse:'tool',
7
+ PostToolUse:'result', PermissionRequest:'codex-permission', Stop:'idle', Interrupt:'idle', PreCompact:'busy'},
8
+ questions:['request_user_input'],
9
+ },
10
+ claude: {
11
+ label: 'Claude', executable: 'claude', config: '.claude/settings.local.json', format: 'nested',
12
+ events: {SessionStart:'start', SessionEnd:'end', UserPromptSubmit:'busy', PreToolUse:'tool',
13
+ PostToolUse:'result', PostToolUseFailure:'result', PermissionRequest:'permission',
14
+ Notification:'notification', Stop:'idle', StopFailure:'error', PreCompact:'busy'},
15
+ questions: ['AskUserQuestion'],
16
+ },
17
+ 'copilot-cli': {
18
+ label: 'Copilot CLI', executable: 'copilot', config: '.github/hooks/agentdeck-copilot-cli.json', format: 'copilot',
19
+ events: {sessionStart:'start', sessionEnd:'end', userPromptSubmitted:'busy', preToolUse:'tool',
20
+ postToolUse:'result', postToolUseFailure:'result', agentStop:'idle', errorOccurred:'error'},
21
+ questions: [], // CLI payloads do not guarantee paired tool-call IDs.
22
+ },
23
+ 'copilot-vscode': {
24
+ label: 'Copilot VS Code', executable: 'code', config: '.github/hooks/agentdeck-copilot-vscode.json', format: 'vscode',
25
+ events: {SessionStart:'start', UserPromptSubmit:'busy', PreToolUse:'tool', PostToolUse:'result',
26
+ Stop:'idle', PreCompact:'busy'}, questions: [],
27
+ },
28
+ gemini: {
29
+ label: 'Gemini', executable: 'gemini', config: '.gemini/settings.json', format: 'nested',
30
+ events: {SessionStart:'start', SessionEnd:'end', BeforeAgent:'busy', BeforeModel:'busy',
31
+ BeforeTool:'tool', AfterTool:'result', AfterAgent:'idle', Notification:'notification', PreCompress:'busy'},
32
+ questions: [],
33
+ },
34
+ cursor: {
35
+ label: 'Cursor CLI', executable: 'agent', config: '.cursor/hooks.json', format: 'cursor',
36
+ events: {sessionStart:'start', sessionEnd:'end', beforeSubmitPrompt:'busy', preToolUse:'tool',
37
+ postToolUse:'result', postToolUseFailure:'result', stop:'idle', preCompact:'busy'}, questions: [],
38
+ },
39
+ };
40
+
41
+ // Allowlist only metadata. Never forward prompts, commands, outputs or transcripts.
42
+ export function normalize(profile, event, input) {
43
+ const p = profiles[profile];
44
+ if (!p || !Object.hasOwn(p.events, event) || !input || typeof input !== 'object' || Array.isArray(input)) return null;
45
+ const str = x => typeof x === 'string' && x.length <= 512 ? x : '';
46
+ const session = str(input.session_id || input.sessionId || input.conversation_id);
47
+ if (!session) return null; // Never guess identity from cwd or hook PID.
48
+ return {event, session, tool: str(input.tool_name || input.toolName),
49
+ request: str(input.tool_use_id || input.toolUseId),
50
+ notification: str(input.notification_type), failed: input.status === 'error' || input.status === 'aborted'};
51
+ }
52
+
53
+ export class HookFacts {
54
+ constructor(profile) {
55
+ if (!profiles[profile]) throw Error('Unknown harness');
56
+ this.profile = profiles[profile]; this.sessions = new Map(); this.seen = false; this.broken = false;
57
+ }
58
+ event(e) {
59
+ const action = this.profile.events[e.event];
60
+ if (!action || typeof e.session !== 'string' || !e.session) return;
61
+ this.seen = true;
62
+ if (action === 'end') { this.sessions.delete(e.session); return; }
63
+ const s = this.sessions.get(e.session) || {status:'unknown', pending:new Set(), detail:'', inputNeeded:false};
64
+ this.sessions.set(e.session, s);
65
+ if (action === 'codex-permission') {
66
+ s.inputNeeded = true; s.status = 'busy'; s.detail = 'Approval requested; count unknown'; return;
67
+ }
68
+ if (['start','idle','busy','result','error'].includes(action)) s.inputNeeded = false;
69
+ if (action === 'notification') {
70
+ // No request ID or resolution event: do not invent a pending count.
71
+ if (e.notification === 'permission_prompt' || e.notification === 'ToolPermission') {
72
+ s.status = 'unknown'; s.detail = 'Permission notification; inspect harness';
73
+ }
74
+ return;
75
+ }
76
+ if (action === 'permission') {
77
+ s.status = 'unknown'; s.detail = 'Permission decision; no paired request ID'; return;
78
+ }
79
+ s.detail = '';
80
+ if (action === 'error' || e.failed) {
81
+ s.status = 'unknown'; s.pending.clear(); s.detail = 'Harness error; inspect terminal';
82
+ } else if (action === 'start' || action === 'idle') {
83
+ s.status = 'idle'; s.pending.clear();
84
+ } else {
85
+ s.status = 'busy';
86
+ if (action === 'busy') s.pending.clear(); // New turn cancels unresolved prior-turn questions.
87
+ if (action === 'tool' && e.request && this.profile.questions.includes(e.tool)) s.pending.add(e.request);
88
+ if (action === 'result' && e.request) s.pending.delete(e.request);
89
+ }
90
+ }
91
+ snapshot() {
92
+ let status = this.seen && !this.broken ? 'idle' : 'unknown', pending = 0, detail = '';
93
+ for (const s of this.sessions.values()) {
94
+ pending += s.pending.size;
95
+ if (s.status === 'unknown') status = 'unknown';
96
+ else if (status !== 'unknown' && s.status === 'busy') status = 'busy';
97
+ if (s.detail) detail = s.detail;
98
+ }
99
+ if (!this.seen) detail = 'Waiting for first harness hook';
100
+ if (this.broken) detail = 'Hook delivery failed; restart managed session';
101
+ const requestIds = [];
102
+ for (const [session, s] of this.sessions) for (const id of s.pending)
103
+ requestIds.push(crypto.createHash('sha256').update(JSON.stringify([session,id])).digest('hex'));
104
+ return {status, pending, detail, pendingKnown:false,
105
+ inputNeeded:[...this.sessions.values()].some(s => s.inputNeeded), requestIds};
106
+ }
107
+ }
@@ -0,0 +1,67 @@
1
+ import {Bridge, Facts, registration} from './core.mjs';
2
+
3
+ // Conventional globally loaded server plugin. No npm package dependencies.
4
+ export async function DeckBridge({client, directory}) {
5
+ if (process.argv.some(a => ['serve', 'run', 'web', 'acp'].includes(a)) && !process.env.OCDECK_BINDING) return {};
6
+ const globalKey = Symbol.for('ryan.ocdeck.server.instance');
7
+ // OpenCode may initialize its server plugin more than once per process.
8
+ // Reuse one adapter; do not allocate a key per project initialization.
9
+ if (globalThis[globalKey]) return globalThis[globalKey];
10
+ let reg;
11
+ // Bindings can appear a few milliseconds after process creation.
12
+ try { reg = await registration(); } catch { return {}; }
13
+ if (!reg) return {};
14
+ const facts = new Facts();
15
+ let eventRevision = 0;
16
+ let lastReconcile = 0;
17
+ let lastError = 0;
18
+ async function reconcile() {
19
+ if (Date.now() - lastReconcile < 4000) return;
20
+ lastReconcile = Date.now();
21
+ const revision = eventRevision;
22
+ // These list methods differ between SDK generations. Missing methods are
23
+ // a diagnosed limitation; live events still provide pending request state.
24
+ const jobs = [];
25
+ if (client.session?.status) jobs.push(['status', client.session.status({query: {directory}})]);
26
+ if (client.permission?.list) jobs.push(['permissions', client.permission.list({query: {directory}})]);
27
+ if (client.question?.list) jobs.push(['questions', client.question.list({query: {directory}})]);
28
+ const results = await Promise.all(jobs.map(async ([key, p]) => [key, await Promise.race([
29
+ p, new Promise((_, reject) => setTimeout(() => reject(Error('Snapshot timed out')), 1000))]) ]));
30
+ if (eventRevision !== revision) return; // Never overwrite an intervening event.
31
+ for (const [key, response] of results) {
32
+ if (response.error) throw Error(`OpenCode ${key} snapshot failed`);
33
+ const data = response.data;
34
+ if (key === 'status' && data && !Array.isArray(data)) {
35
+ for (const [id, s] of facts.sessions) s.status = data[id]?.type || 'idle';
36
+ for (const [id, status] of Object.entries(data)) facts.session(id).status = status.type;
37
+ } else if (Array.isArray(data)) {
38
+ for (const s of facts.sessions.values()) s[key].clear();
39
+ for (const r of data) facts.session(r.sessionID)[key].add(r.id);
40
+ }
41
+ }
42
+ facts.trusted = true;
43
+ if (facts.detail === 'OpenCode snapshot unavailable') facts.detail = '';
44
+ }
45
+ const bridge = new Bridge({registration: reg,
46
+ readSnapshot: async () => {
47
+ try { await reconcile(); }
48
+ catch { facts.trusted = false; facts.detail = 'OpenCode snapshot unavailable'; }
49
+ return facts.snapshot();
50
+ },
51
+ onError: error => {
52
+ if (Date.now() - lastError < 30000) return;
53
+ lastError = Date.now();
54
+ try { Promise.resolve(client.app?.log?.({body: {service: 'ocdeck', level: 'warn', message: error}})).catch(() => {}); } catch {}
55
+ }});
56
+ bridge.start();
57
+ const hooks = {
58
+ event: async ({event}) => {
59
+ if (/^(session\.|permission\.|question\.)/.test(event.type)) {
60
+ eventRevision++; facts.event(event); void bridge.flush();
61
+ }
62
+ },
63
+ dispose: async () => { await bridge.close(); delete globalThis[globalKey]; }
64
+ };
65
+ globalThis[globalKey] = hooks;
66
+ return hooks;
67
+ }
@@ -0,0 +1,44 @@
1
+ import {Bridge, registration} from './core.mjs';
2
+
3
+ // Opt-in replacement for server adapter, after verifying installed TUI support.
4
+ export default {
5
+ id: 'ryan.opencode.deck',
6
+ async tui(api) {
7
+ const reg = await registration();
8
+ if (!reg) return;
9
+ const known = new Set();
10
+ const snapshot = () => {
11
+ if (!api.state.ready) return {status: 'unknown', pending: 0};
12
+ const route = api.route.current;
13
+ if (route.name === 'session' && route.params?.sessionID) known.add(route.params.sessionID);
14
+ let status = 'idle', pending = 0;
15
+ for (const id of known) {
16
+ const s = api.state.session.status(id);
17
+ if (s?.type === 'busy' || s?.type === 'retry') status = 'busy';
18
+ pending += api.state.session.permission(id).length + api.state.session.question(id).length;
19
+ }
20
+ return {status, pending};
21
+ };
22
+ const bridge = new Bridge({registration: reg, readSnapshot: snapshot});
23
+ const events = ['session.status', 'permission.asked', 'permission.replied',
24
+ 'question.asked', 'question.replied', 'question.rejected'];
25
+ const unsubscribe = events.map(type => api.event.on(type, event => {
26
+ const id = event.properties?.sessionID;
27
+ // Include descendant activity only when its root belongs to this TUI.
28
+ let s = id && api.state.session.get(id), visited = new Set();
29
+ while (s?.parentID && !visited.has(s.id)) {
30
+ visited.add(s.id);
31
+ if (known.has(s.parentID)) { known.add(id); break; }
32
+ s = api.state.session.get(s.parentID);
33
+ }
34
+ setTimeout(() => void bridge.flush(), 0);
35
+ }));
36
+ bridge.start();
37
+ // Local state observation also catches TUI route changes without a server event.
38
+ const timer = setInterval(() => void bridge.flush(), 500);
39
+ timer.unref?.();
40
+ api.lifecycle.onDispose(async () => {
41
+ clearInterval(timer); unsubscribe.forEach(f => f()); await bridge.close();
42
+ });
43
+ }
44
+ };
@@ -0,0 +1,20 @@
1
+ param(
2
+ [Parameter(Mandatory=$true)]
3
+ [ValidateSet('claude','copilot-cli','copilot-vscode','gemini','cursor','codex')][string]$Profile,
4
+ [string]$Project = (Get-Location).Path,
5
+ [switch]$Remove,
6
+ [switch]$DryRun
7
+ )
8
+ $ErrorActionPreference = 'Stop'
9
+ $source = Split-Path -Parent $PSScriptRoot
10
+ $runtime = Join-Path $env:USERPROFILE '.opencode-deck\venv\Scripts\python.exe'
11
+ if (-not (Test-Path -LiteralPath $runtime)) { $runtime = 'python' }
12
+ $oldPythonPath = $env:PYTHONPATH
13
+ try {
14
+ $env:PYTHONPATH = $source + [IO.Path]::PathSeparator + $oldPythonPath
15
+ $arguments = @('-m','ocdeck','harness-install',$Profile,'--project',$Project)
16
+ if ($Remove) { $arguments += '--remove' }
17
+ if ($DryRun) { $arguments += '--dry-run' }
18
+ & $runtime @arguments
19
+ if ($LASTEXITCODE -ne 0) { throw 'Harness configuration failed; inspect the message above.' }
20
+ } finally { $env:PYTHONPATH = $oldPythonPath }
@@ -0,0 +1,79 @@
1
+ param(
2
+ [string]$Python = 'python',
3
+ [string]$OpenCodePath,
4
+ [string]$ConfigDirectory,
5
+ [ValidateSet('server','tui')][string]$PluginMode = 'server'
6
+ )
7
+ $ErrorActionPreference = 'Stop'
8
+ $source = Split-Path -Parent $PSScriptRoot
9
+ $data = Join-Path $env:USERPROFILE '.opencode-deck'
10
+ $old = $null
11
+ if (Test-Path (Join-Path $data 'install.json')) {
12
+ $old = Get-Content (Join-Path $data 'install.json') -Raw | ConvertFrom-Json
13
+ }
14
+ if (-not $OpenCodePath) {
15
+ if ($old -and (Test-Path -LiteralPath $old.opencode)) { $OpenCodePath = $old.opencode }
16
+ else {
17
+ $command = Get-Command opencode -ErrorAction SilentlyContinue
18
+ if ($command) { $OpenCodePath = $command.Source }
19
+ }
20
+ }
21
+ if (-not $OpenCodePath -or -not (Test-Path -LiteralPath $OpenCodePath)) {
22
+ throw 'Install OpenCode first, or pass -OpenCodePath with its actual executable/shim path.'
23
+ }
24
+ if ($OpenCodePath.StartsWith((Join-Path $data 'bin'), [StringComparison]::OrdinalIgnoreCase)) {
25
+ throw 'OpenCodePath points to this launcher. Supply the original OpenCode executable.'
26
+ }
27
+ & $Python -c 'import sys; assert sys.version_info >= (3,11), "Python 3.11 or newer required"'
28
+ if ($LASTEXITCODE -ne 0) { throw 'Python 3.11+ is required; use -Python with its executable path.' }
29
+ New-Item -ItemType Directory -Force -Path $data | Out-Null
30
+ # Per-user token/discovery protection. Do not broaden ACLs or require an administrator.
31
+ $user = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
32
+ & icacls.exe $data /inheritance:r /grant:r "${user}:(OI)(CI)F" 'SYSTEM:(OI)(CI)F' | Out-Null
33
+ if ($LASTEXITCODE -ne 0) { throw 'Could not secure the per-user configuration directory.' }
34
+ $existingTask = Get-ScheduledTask -TaskName 'OpenCode Deck' -TaskPath '\' -ErrorAction SilentlyContinue
35
+ if ($existingTask -and $existingTask.Description -notlike 'OpenCode Deck*') {
36
+ throw 'An unrelated task already uses the name OpenCode Deck; refusing to replace it.'
37
+ }
38
+ if ($existingTask) { Stop-ScheduledTask -TaskName 'OpenCode Deck' -TaskPath '\' }
39
+ $venv = Join-Path $data 'venv'
40
+ & $Python -m venv $venv
41
+ if ($LASTEXITCODE -ne 0) { throw 'Virtual environment creation failed.' }
42
+ $runtime = Join-Path $venv 'Scripts\python.exe'
43
+ & $runtime -m pip install --disable-pip-version-check -e $source
44
+ if ($LASTEXITCODE -ne 0) { throw 'Dependency installation failed. Check Internet access and Python architecture.' }
45
+ $install = @{ python=$runtime; source=$source; opencode=$OpenCodePath; pluginMode=$PluginMode }
46
+ $install | ConvertTo-Json | Set-Content -Encoding UTF8 (Join-Path $data 'install.json')
47
+ if (-not (Test-Path (Join-Path $data 'config.json'))) {
48
+ @{fps=24;brightness=45;animations=$true;ready=$true;serial=$null} | ConvertTo-Json | Set-Content -Encoding UTF8 (Join-Path $data 'config.json')
49
+ }
50
+ $pluginArgs = @('-m','ocdeck','install-plugin','--mode',$PluginMode)
51
+ if ($ConfigDirectory) { $pluginArgs += @('--config-dir',$ConfigDirectory) }
52
+ & $runtime @pluginArgs
53
+ if ($LASTEXITCODE -ne 0) { throw 'Plugin configuration failed. Read the message above; existing config was preserved.' }
54
+ $bin = Join-Path $data 'bin'
55
+ New-Item -ItemType Directory -Force -Path $bin | Out-Null
56
+ $cli = '@echo off' + "`r`n" + '"' + $runtime + '" -m ocdeck %*' + "`r`n"
57
+ $launch = '@echo off' + "`r`n" + '"' + $runtime + '" -m ocdeck launch -- %*' + "`r`n"
58
+ $route = '@echo off' + "`r`n" + '"' + $runtime + '" -m ocdeck route -- %*' + "`r`n"
59
+ Set-Content -LiteralPath (Join-Path $bin 'ocdeck.cmd') -Value $cli -Encoding ASCII
60
+ Set-Content -LiteralPath (Join-Path $bin 'oc.cmd') -Value $launch -Encoding ASCII
61
+ Set-Content -LiteralPath (Join-Path $bin 'opencode.cmd') -Value $route -Encoding ASCII
62
+ $userPath = [Environment]::GetEnvironmentVariable('Path','User')
63
+ $entries = @($userPath -split ';' | Where-Object { $_ -and $_ -ne $bin })
64
+ [Environment]::SetEnvironmentVariable('Path', (($bin + ';' + ($entries -join ';')).TrimEnd(';')), 'User')
65
+ $env:Path = $bin + ';' + $env:Path
66
+ $pythonw = Join-Path $venv 'Scripts\pythonw.exe'
67
+ $action = New-ScheduledTaskAction -Execute $pythonw -Argument '-m ocdeck broker' -WorkingDirectory $data
68
+ $trigger = New-ScheduledTaskTrigger -AtLogOn -User $user
69
+ $principal = New-ScheduledTaskPrincipal -UserId $user -LogonType Interactive -RunLevel Limited
70
+ $settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -MultipleInstances IgnoreNew -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit ([TimeSpan]::Zero) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
71
+ Register-ScheduledTask -TaskName 'OpenCode Deck' -TaskPath '\' -Description 'OpenCode Deck animated Mini broker (per-user interactive logon)' -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null
72
+ Start-ScheduledTask -TaskName 'OpenCode Deck' -TaskPath '\'
73
+ Write-Host ''
74
+ Write-Host 'Installed. Disable this Mini in Elgato Preferences > Devices.'
75
+ Write-Host 'Open a NEW terminal, run: opencode (or: oc / ocdeck launch)'
76
+ Write-Host 'Verify Get-Command opencode resolves to .opencode-deck\bin\opencode.cmd.'
77
+ Write-Host 'Global plugin also loads for normal opencode launches; managed windows give reliable identity.'
78
+ Write-Host 'Run: ocdeck status and read docs\FIRST-RUN.md for physical acceptance tests.'
79
+ Write-Host "Keep this bundle directory at: $source"
@@ -0,0 +1,7 @@
1
+ @echo off
2
+ setlocal
3
+ set "PYTHONPATH=%~dp0..;%PYTHONPATH%"
4
+ set "AGENTDECK_PYTHON=%USERPROFILE%\.opencode-deck\venv\Scripts\python.exe"
5
+ if not exist "%AGENTDECK_PYTHON%" set "AGENTDECK_PYTHON=python"
6
+ "%AGENTDECK_PYTHON%" -m ocdeck start %*
7
+ exit /b %ERRORLEVEL%
@@ -0,0 +1,7 @@
1
+ @echo off
2
+ setlocal
3
+ set "PYTHONPATH=%~dp0..;%PYTHONPATH%"
4
+ set "AGENTDECK_PYTHON=%USERPROFILE%\.opencode-deck\venv\Scripts\python.exe"
5
+ if not exist "%AGENTDECK_PYTHON%" set "AGENTDECK_PYTHON=python"
6
+ "%AGENTDECK_PYTHON%" -m ocdeck harness-launch --profile claude -- %*
7
+ exit /b %ERRORLEVEL%
@@ -0,0 +1,7 @@
1
+ @echo off
2
+ setlocal
3
+ set "PYTHONPATH=%~dp0..;%PYTHONPATH%"
4
+ set "AGENTDECK_PYTHON=%USERPROFILE%\.opencode-deck\venv\Scripts\python.exe"
5
+ if not exist "%AGENTDECK_PYTHON%" set "AGENTDECK_PYTHON=python"
6
+ "%AGENTDECK_PYTHON%" -m ocdeck harness-launch --profile codex -- %*
7
+ exit /b %ERRORLEVEL%
@@ -0,0 +1,7 @@
1
+ @echo off
2
+ setlocal
3
+ set "PYTHONPATH=%~dp0..;%PYTHONPATH%"
4
+ set "AGENTDECK_PYTHON=%USERPROFILE%\.opencode-deck\venv\Scripts\python.exe"
5
+ if not exist "%AGENTDECK_PYTHON%" set "AGENTDECK_PYTHON=python"
6
+ "%AGENTDECK_PYTHON%" -m ocdeck harness-launch --profile copilot-vscode -- %*
7
+ exit /b %ERRORLEVEL%
@@ -0,0 +1,7 @@
1
+ @echo off
2
+ setlocal
3
+ set "PYTHONPATH=%~dp0..;%PYTHONPATH%"
4
+ set "AGENTDECK_PYTHON=%USERPROFILE%\.opencode-deck\venv\Scripts\python.exe"
5
+ if not exist "%AGENTDECK_PYTHON%" set "AGENTDECK_PYTHON=python"
6
+ "%AGENTDECK_PYTHON%" -m ocdeck harness-launch --profile copilot-cli -- %*
7
+ exit /b %ERRORLEVEL%
@@ -0,0 +1,7 @@
1
+ @echo off
2
+ setlocal
3
+ set "PYTHONPATH=%~dp0..;%PYTHONPATH%"
4
+ set "AGENTDECK_PYTHON=%USERPROFILE%\.opencode-deck\venv\Scripts\python.exe"
5
+ if not exist "%AGENTDECK_PYTHON%" set "AGENTDECK_PYTHON=python"
6
+ "%AGENTDECK_PYTHON%" -m ocdeck harness-launch --profile cursor -- %*
7
+ exit /b %ERRORLEVEL%
@@ -0,0 +1,7 @@
1
+ @echo off
2
+ setlocal
3
+ set "PYTHONPATH=%~dp0..;%PYTHONPATH%"
4
+ set "AGENTDECK_PYTHON=%USERPROFILE%\.opencode-deck\venv\Scripts\python.exe"
5
+ if not exist "%AGENTDECK_PYTHON%" set "AGENTDECK_PYTHON=python"
6
+ "%AGENTDECK_PYTHON%" -m ocdeck harness-launch --profile gemini -- %*
7
+ exit /b %ERRORLEVEL%
@@ -0,0 +1,35 @@
1
+ param([Parameter(Mandatory=$true)][string]$Data)
2
+ $ErrorActionPreference = 'Stop'
3
+ $task = Get-ScheduledTask -TaskName 'OpenCode Deck' -TaskPath '\' -ErrorAction SilentlyContinue
4
+ if ($task) {
5
+ if ($task.Description -notlike 'OpenCode Deck*') { throw 'Unrelated scheduled task; refusing removal.' }
6
+ Stop-ScheduledTask -TaskName 'OpenCode Deck' -TaskPath '\'
7
+ Unregister-ScheduledTask -TaskName 'OpenCode Deck' -TaskPath '\' -Confirm:$false
8
+ }
9
+ $env:OCDECK_HOME = $Data
10
+
11
+ $metadata = Join-Path $Data 'install.json'
12
+ if (Test-Path -LiteralPath $metadata) {
13
+ $install = Get-Content -LiteralPath $metadata -Raw | ConvertFrom-Json
14
+ if ($install.configDir) {
15
+ $entry = Join-Path $install.configDir 'plugins\ocdeck.js'
16
+ if ((Test-Path -LiteralPath $entry) -and (Get-Content -LiteralPath $entry -Raw).StartsWith('// Managed by OpenCode Deck installer')) {
17
+ Move-Item -LiteralPath $entry -Destination ($entry + '.agentdeck-backup-' + [DateTime]::UtcNow.Ticks)
18
+ }
19
+ $tui = Join-Path $install.configDir 'tui.json'
20
+ if ((Test-Path -LiteralPath $tui) -and $install.source) {
21
+ $config = Get-Content -LiteralPath $tui -Raw | ConvertFrom-Json
22
+ $uri = ([System.Uri](Join-Path $install.source 'plugins\tui.mjs')).AbsoluteUri
23
+ if ($config.plugin -contains $uri) {
24
+ Copy-Item -LiteralPath $tui -Destination ($tui + '.agentdeck-backup-' + [DateTime]::UtcNow.Ticks)
25
+ $config.plugin = @($config.plugin | Where-Object { $_ -ne $uri })
26
+ $config | ConvertTo-Json -Depth 100 | Set-Content -LiteralPath $tui -Encoding UTF8
27
+ }
28
+ }
29
+ }
30
+ }
31
+ $bin = Join-Path $Data 'bin'
32
+ $entries = @([Environment]::GetEnvironmentVariable('Path','User') -split ';' | Where-Object { $_ -and $_ -ne $bin })
33
+ [Environment]::SetEnvironmentVariable('Path', ($entries -join ';'), 'User')
34
+
35
+ Remove-Item -LiteralPath 'HKCU:\Software\Classes\AppUserModelId\AgentDeck' -ErrorAction SilentlyContinue
@@ -0,0 +1,7 @@
1
+ param([Parameter(Mandatory=$true)][string]$LaunchFile)
2
+ $ErrorActionPreference = 'Stop'
3
+ $spec = Get-Content -LiteralPath $LaunchFile -Raw | ConvertFrom-Json
4
+ Set-Location -LiteralPath $spec.cwd
5
+ $arguments = @($spec.args)
6
+ & $spec.executable @arguments
7
+ exit $LASTEXITCODE
@@ -0,0 +1,11 @@
1
+ $ErrorActionPreference = 'Stop'
2
+ $source = Split-Path -Parent $PSScriptRoot
3
+ $runtime = Join-Path $env:USERPROFILE '.opencode-deck\venv\Scripts\python.exe'
4
+ if (-not (Test-Path $runtime)) { $runtime = 'python' }
5
+ Push-Location $source
6
+ try {
7
+ & $runtime -m unittest discover -s tests -v
8
+ if ($LASTEXITCODE -ne 0) { throw 'Python/integration tests failed' }
9
+ & node --test tests/facts.test.mjs tests/harnesses.test.mjs tests/next.test.mjs
10
+ if ($LASTEXITCODE -ne 0) { throw 'JavaScript tests failed' }
11
+ } finally { Pop-Location }
@@ -0,0 +1,11 @@
1
+ param([switch]$DryRun, [string[]]$Scan = @())
2
+ $ErrorActionPreference = 'Stop'
3
+ $data = if ($env:OCDECK_HOME) { $env:OCDECK_HOME } else { Join-Path $env:USERPROFILE '.opencode-deck' }
4
+ $metadata = Join-Path $data 'install.json'
5
+ if (-not (Test-Path -LiteralPath $metadata)) { throw 'No installation metadata. Run ocdeck uninstall --all from the installed Python environment.' }
6
+ $install = Get-Content -LiteralPath $metadata -Raw | ConvertFrom-Json
7
+ $argsList = @('-m','ocdeck','uninstall','--all')
8
+ if ($DryRun) { $argsList += '--dry-run' }
9
+ foreach ($directory in $Scan) { $argsList += @('--scan', $directory) }
10
+ & $install.python @argsList
11
+ exit $LASTEXITCODE
@@ -0,0 +1,13 @@
1
+ $ErrorActionPreference = 'Stop'
2
+ $data = Join-Path $env:USERPROFILE '.opencode-deck'
3
+ $runtime = Join-Path $data 'venv\Scripts\python.exe'
4
+ Write-Host 'Command resolution:'
5
+ Get-Command opencode,oc,ocdeck -ErrorAction SilentlyContinue | Select-Object Name,Source
6
+ Write-Host 'Scheduled task at root:'
7
+ Get-ScheduledTask -TaskName 'OpenCode Deck' -TaskPath '\' | Select-Object TaskName,TaskPath,State
8
+ Get-ScheduledTaskInfo -TaskName 'OpenCode Deck' -TaskPath '\' | Select-Object LastRunTime,LastTaskResult
9
+ Write-Host 'Native device inventory:'
10
+ & $runtime -m ocdeck devices
11
+ Write-Host 'Broker status (mock must be false on your PC):'
12
+ & $runtime -m ocdeck status
13
+ Write-Host 'These checks do not prove physical imagery or keyboard focus. Follow FIRST-RUN.md.'
@@ -0,0 +1,8 @@
1
+ """Check every pre-commit input; node --check accepts only one source file."""
2
+ import subprocess
3
+ import sys
4
+
5
+ for filename in sys.argv[1:]:
6
+ result = subprocess.run(['node', '--check', filename])
7
+ if result.returncode:
8
+ raise SystemExit(result.returncode)
@@ -0,0 +1,6 @@
1
+ @echo off
2
+ setlocal
3
+ REM Use normal Anthropic login/API-key settings, without inherited local routing.
4
+ for %%V in (ANTHROPIC_BASE_URL ANTHROPIC_AUTH_TOKEN ANTHROPIC_MODEL ANTHROPIC_DEFAULT_OPUS_MODEL ANTHROPIC_DEFAULT_SONNET_MODEL ANTHROPIC_DEFAULT_HAIKU_MODEL ANTHROPIC_DEFAULT_FABLE_MODEL CLAUDE_CODE_SUBAGENT_MODEL ANTHROPIC_CUSTOM_MODEL_OPTION ANTHROPIC_CUSTOM_MODEL_OPTION_NAME CLAUDE_CODE_USE_BEDROCK CLAUDE_CODE_USE_VERTEX CLAUDE_CODE_USE_FOUNDRY) do set "%%V="
5
+ call "%~dp0..\Launch-Agent.bat" --profile claude -- %*
6
+ exit /b %ERRORLEVEL%
@@ -0,0 +1,21 @@
1
+ @echo off
2
+ setlocal
3
+ if not defined AGENTDECK_LOCAL_URL (
4
+ echo Set AGENTDECK_LOCAL_URL to the Anthropic-compatible server base URL.
5
+ exit /b 2
6
+ )
7
+ if not defined AGENTDECK_LOCAL_MODEL (
8
+ echo Set AGENTDECK_LOCAL_MODEL to the model ID served by that endpoint.
9
+ exit /b 2
10
+ )
11
+ set "ANTHROPIC_BASE_URL=%AGENTDECK_LOCAL_URL%"
12
+ set "ANTHROPIC_API_KEY="
13
+ set "ANTHROPIC_AUTH_TOKEN=%AGENTDECK_LOCAL_TOKEN%"
14
+ if not defined ANTHROPIC_AUTH_TOKEN set "ANTHROPIC_AUTH_TOKEN=local-dev"
15
+ set "ANTHROPIC_MODEL=%AGENTDECK_LOCAL_MODEL%"
16
+ set "ANTHROPIC_DEFAULT_OPUS_MODEL=%AGENTDECK_LOCAL_MODEL%"
17
+ set "ANTHROPIC_DEFAULT_SONNET_MODEL=%AGENTDECK_LOCAL_MODEL%"
18
+ set "ANTHROPIC_DEFAULT_HAIKU_MODEL=%AGENTDECK_LOCAL_MODEL%"
19
+ set "CLAUDE_CODE_SUBAGENT_MODEL=%AGENTDECK_LOCAL_MODEL%"
20
+ call "%~dp0..\Launch-Agent.bat" --profile claude -- --model "%AGENTDECK_LOCAL_MODEL%" %*
21
+ exit /b %ERRORLEVEL%
@@ -0,0 +1,8 @@
1
+ @echo off
2
+ setlocal
3
+ if not defined HOMEAILAB_ROOT (
4
+ echo Set HOMEAILAB_ROOT to the HomeAILab checkout directory first.
5
+ exit /b 2
6
+ )
7
+ call "%~dp0..\Launch-Agent.bat" --profile claude --launcher "%HOMEAILAB_ROOT%\harness\claude\claude-5090.bat" -- %*
8
+ exit /b %ERRORLEVEL%
@@ -0,0 +1,8 @@
1
+ @echo off
2
+ setlocal
3
+ if not defined HOMEAILAB_ROOT (
4
+ echo Set HOMEAILAB_ROOT to the HomeAILab checkout directory first.
5
+ exit /b 2
6
+ )
7
+ call "%~dp0..\Launch-Agent.bat" --profile claude --launcher "%HOMEAILAB_ROOT%\harness\claude\claude-cluster.bat" -- %*
8
+ exit /b %ERRORLEVEL%
@@ -0,0 +1,8 @@
1
+ @echo off
2
+ setlocal
3
+ if not defined HOMEAILAB_ROOT (
4
+ echo Set HOMEAILAB_ROOT to the HomeAILab checkout directory first.
5
+ exit /b 2
6
+ )
7
+ call "%~dp0..\Launch-Agent.bat" --profile opencode --launcher "%HOMEAILAB_ROOT%\harness\opencode\opencode-5090.bat" -- %*
8
+ exit /b %ERRORLEVEL%
@@ -0,0 +1,8 @@
1
+ @echo off
2
+ setlocal
3
+ if not defined HOMEAILAB_ROOT (
4
+ echo Set HOMEAILAB_ROOT to the HomeAILab checkout directory first.
5
+ exit /b 2
6
+ )
7
+ call "%~dp0..\Launch-Agent.bat" --profile opencode --launcher "%HOMEAILAB_ROOT%\harness\opencode\opencode-spark.bat" -- %*
8
+ exit /b %ERRORLEVEL%